Skip to content

Commit 0c97a07

Browse files
Nfrederiksenclaude
andcommitted
Add HA and concurrent tick test coverage
Tests leader-follower state isolation, _lastTickState consistency across VOD transitions and slate insertion, and concurrent multi-channel ticking with media sequence monotonicity checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ecda5bc commit 0c97a07

1 file changed

Lines changed: 364 additions & 0 deletions

File tree

spec/engine/ha_spec.js

Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
1+
const Session = require('../../engine/session.js');
2+
const m3u8 = require('@eyevinn/m3u8');
3+
const Readable = require('stream').Readable;
4+
5+
const { SessionStateStore } = require('../../engine/session_state.js');
6+
const { PlayheadStateStore } = require('../../engine/playhead_state.js');
7+
8+
class TestAssetManager {
9+
constructor(opts, assets) {
10+
this.assets = [
11+
{ id: 1, title: "Tears of Steel", uri: "https://maitv-vod.lab.eyevinn.technology/tearsofsteel_4k.mov/master.m3u8" },
12+
{ id: 2, title: "VINN", uri: "https://maitv-vod.lab.eyevinn.technology/VINN.mp4/master.m3u8" }
13+
];
14+
if (assets) {
15+
this.assets = assets;
16+
}
17+
this.pos = 0;
18+
this.doFail = false;
19+
if (opts && opts.fail) {
20+
this.doFail = true;
21+
}
22+
}
23+
getNextVod(vodRequest) {
24+
return new Promise((resolve, reject) => {
25+
if (this.doFail) {
26+
reject("should fail");
27+
} else {
28+
const vod = this.assets[this.pos++];
29+
if (this.pos > this.assets.length - 1) {
30+
this.pos = 0;
31+
}
32+
resolve(vod);
33+
}
34+
});
35+
}
36+
}
37+
38+
const parseMediaManifest = async (manifest) => {
39+
const parser = m3u8.createStream();
40+
const m3u = await new Promise((resolve, reject) => {
41+
let manifestStream = new Readable();
42+
manifestStream.push(manifest);
43+
manifestStream.push(null);
44+
manifestStream.pipe(parser);
45+
parser.on('m3u', m3u => {
46+
resolve(m3u);
47+
});
48+
});
49+
return m3u;
50+
};
51+
52+
describe("High Availability", () => {
53+
describe("Leader-Follower with shared state", () => {
54+
let sessionStateStore;
55+
let playheadStateStore;
56+
57+
beforeEach(() => {
58+
sessionStateStore = new SessionStateStore();
59+
playheadStateStore = new PlayheadStateStore();
60+
});
61+
62+
it("leader writes state that follower can read", async () => {
63+
const assetMgr = new TestAssetManager();
64+
const leaderStore = {
65+
sessionStateStore,
66+
playheadStateStore,
67+
instanceId: "leader-instance",
68+
};
69+
const followerStore = {
70+
sessionStateStore,
71+
playheadStateStore,
72+
instanceId: "follower-instance",
73+
};
74+
75+
// Leader session inits first — becomes the leader
76+
const leaderSession = new Session(assetMgr, { sessionId: '1' }, leaderStore);
77+
await leaderSession.initAsync();
78+
79+
// Follower session shares same sessionId and stores
80+
const followerSession = new Session(assetMgr, { sessionId: '1' }, followerStore);
81+
await followerSession.initAsync();
82+
83+
// Leader increments — should write state
84+
await leaderSession.incrementAsync();
85+
const leaderManifest = await leaderSession.getCurrentMediaManifestAsync(180000);
86+
expect(leaderManifest).not.toBeNull();
87+
88+
// Follower increments — should read leader's state, not write
89+
await followerSession.incrementAsync();
90+
const followerManifest = await followerSession.getCurrentMediaManifestAsync(180000);
91+
expect(followerManifest).not.toBeNull();
92+
93+
// Both should produce valid manifests
94+
const leaderM3u = await parseMediaManifest(leaderManifest);
95+
const followerM3u = await parseMediaManifest(followerManifest);
96+
expect(leaderM3u.get('mediaSequence')).toBeDefined();
97+
expect(followerM3u.get('mediaSequence')).toBeDefined();
98+
});
99+
100+
it("follower does not overwrite leader's vodMediaSeqVideo", async () => {
101+
const assetMgr = new TestAssetManager();
102+
const leaderStore = {
103+
sessionStateStore,
104+
playheadStateStore,
105+
instanceId: "leader-instance",
106+
};
107+
const followerStore = {
108+
sessionStateStore,
109+
playheadStateStore,
110+
instanceId: "follower-instance",
111+
};
112+
113+
const leaderSession = new Session(assetMgr, { sessionId: '1' }, leaderStore);
114+
await leaderSession.initAsync();
115+
116+
const followerSession = new Session(assetMgr, { sessionId: '1' }, followerStore);
117+
await followerSession.initAsync();
118+
119+
// Leader advances several times
120+
for (let i = 0; i < 5; i++) {
121+
await leaderSession.incrementAsync();
122+
}
123+
124+
// Read the shared state directly — leader incremented 5 times
125+
const stateAfterLeader = await sessionStateStore.get('1', 'vodMediaSeqVideo');
126+
expect(stateAfterLeader).toEqual(5);
127+
128+
// Follower increments — should NOT change vodMediaSeqVideo in store
129+
// (follower reads but doesn't write via setValues since isLeader=false)
130+
await followerSession.incrementAsync();
131+
const stateAfterFollower = await sessionStateStore.get('1', 'vodMediaSeqVideo');
132+
expect(stateAfterFollower).toEqual(5); // Follower must not overwrite leader's value
133+
});
134+
135+
it("leader and follower media sequences stay monotonically increasing", async () => {
136+
const assetMgr = new TestAssetManager();
137+
const leaderStore = {
138+
sessionStateStore,
139+
playheadStateStore,
140+
instanceId: "leader-instance",
141+
};
142+
const followerStore = {
143+
sessionStateStore,
144+
playheadStateStore,
145+
instanceId: "follower-instance",
146+
};
147+
148+
const leaderSession = new Session(assetMgr, { sessionId: '1' }, leaderStore);
149+
await leaderSession.initAsync();
150+
const followerSession = new Session(assetMgr, { sessionId: '1' }, followerStore);
151+
await followerSession.initAsync();
152+
153+
let lastLeaderMseq = -1;
154+
let lastFollowerMseq = -1;
155+
156+
for (let i = 0; i < 20; i++) {
157+
const leaderManifest = await leaderSession.incrementAsync();
158+
if (leaderManifest) {
159+
const lm = leaderManifest.match(/#EXT-X-MEDIA-SEQUENCE:(\d+)/);
160+
if (lm) {
161+
const mseq = Number(lm[1]);
162+
expect(mseq).toBeGreaterThanOrEqual(lastLeaderMseq);
163+
lastLeaderMseq = mseq;
164+
}
165+
}
166+
167+
const followerManifest = await followerSession.incrementAsync();
168+
if (followerManifest) {
169+
const fm = followerManifest.match(/#EXT-X-MEDIA-SEQUENCE:(\d+)/);
170+
if (fm) {
171+
const mseq = Number(fm[1]);
172+
expect(mseq).toBeGreaterThanOrEqual(lastFollowerMseq);
173+
lastFollowerMseq = mseq;
174+
}
175+
}
176+
}
177+
178+
// Both should have progressed
179+
expect(lastLeaderMseq).toBeGreaterThan(0);
180+
expect(lastFollowerMseq).toBeGreaterThan(0);
181+
});
182+
});
183+
184+
describe("_lastTickState consistency", () => {
185+
let sessionStore;
186+
187+
beforeEach(() => {
188+
sessionStore = {
189+
sessionStateStore: new SessionStateStore(),
190+
playheadStateStore: new PlayheadStateStore(),
191+
instanceId: "test-instance",
192+
};
193+
});
194+
195+
it("_lastTickState is populated after incrementAsync", async () => {
196+
const assetMgr = new TestAssetManager();
197+
const session = new Session(assetMgr, { sessionId: '1' }, sessionStore);
198+
await session.initAsync();
199+
await session.incrementAsync();
200+
201+
expect(session._lastTickState).toBeDefined();
202+
expect(session._lastTickState.sessionState).toBeDefined();
203+
expect(session._lastTickState.isLeader).toBe(true);
204+
expect(session._lastTickState.currentVod).toBeDefined();
205+
});
206+
207+
it("_lastTickState reflects correct state after VOD transition", async () => {
208+
const assetMgr = new TestAssetManager(null, [
209+
{ id: 1, title: "Short", uri: "https://maitv-vod.lab.eyevinn.technology/VINN.mp4/master.m3u8" }
210+
]);
211+
const session = new Session(assetMgr, { sessionId: '1' }, sessionStore);
212+
await session.initAsync();
213+
214+
let lastState = null;
215+
// Run enough increments to cross a VOD boundary
216+
for (let i = 0; i < 20; i++) {
217+
await session.incrementAsync();
218+
lastState = session._lastTickState;
219+
}
220+
221+
expect(lastState.sessionState).toBeDefined();
222+
expect(lastState.currentVod).not.toBeNull();
223+
// vodMediaSeqVideo should be a valid non-negative number
224+
expect(lastState.sessionState.vodMediaSeqVideo).toBeGreaterThanOrEqual(0);
225+
});
226+
227+
it("_lastTickState has correct state after slate insertion", async () => {
228+
const assetMgr = new TestAssetManager({ fail: true });
229+
const session = new Session(assetMgr, {
230+
sessionId: '1',
231+
slateUri: 'http://testcontent.eyevinn.technology/slates/ottera/playlist.m3u8'
232+
}, sessionStore);
233+
await session.initAsync();
234+
235+
await session.incrementAsync();
236+
237+
// After slate insertion + incrementAsync processing, _lastTickState should have valid state
238+
expect(session._lastTickState).toBeDefined();
239+
expect(session._lastTickState.sessionState).toBeDefined();
240+
expect(session._lastTickState.currentVod).not.toBeNull();
241+
// State is VOD_PLAYING (2) because incrementAsync detects VOD_NEXT_INITIATING
242+
// from _tickAsync and transitions it to VOD_PLAYING
243+
expect(session._lastTickState.sessionState.state).toEqual(2); // VOD_PLAYING
244+
});
245+
});
246+
});
247+
248+
describe("Concurrent ticks", () => {
249+
let sessionStateStore;
250+
let playheadStateStore;
251+
252+
beforeEach(() => {
253+
sessionStateStore = new SessionStateStore();
254+
playheadStateStore = new PlayheadStateStore();
255+
});
256+
257+
it("multiple channels ticking concurrently do not corrupt each other's state", async () => {
258+
const channels = [];
259+
const numChannels = 4;
260+
261+
for (let i = 0; i < numChannels; i++) {
262+
const assetMgr = new TestAssetManager();
263+
const store = {
264+
sessionStateStore,
265+
playheadStateStore,
266+
instanceId: "instance-1",
267+
};
268+
const session = new Session(assetMgr, { sessionId: `ch-${i}` }, store);
269+
await session.initAsync();
270+
channels.push(session);
271+
}
272+
273+
// Tick all channels concurrently for several rounds
274+
for (let round = 0; round < 10; round++) {
275+
await Promise.all(channels.map(ch => ch.incrementAsync()));
276+
}
277+
278+
// Each channel should have valid, independent state
279+
for (let i = 0; i < numChannels; i++) {
280+
const manifest = await channels[i].getCurrentMediaManifestAsync(180000);
281+
expect(manifest).not.toBeNull();
282+
283+
const m = manifest.match(/#EXT-X-MEDIA-SEQUENCE:(\d+)/);
284+
expect(m).not.toBeNull();
285+
const mseq = Number(m[1]);
286+
// Each channel started at 0 and ticked 10 times
287+
expect(mseq).toBeGreaterThanOrEqual(10);
288+
}
289+
});
290+
291+
it("concurrent ticks produce monotonically increasing media sequences per channel", async () => {
292+
const numChannels = 3;
293+
const channels = [];
294+
const lastMseqs = new Array(numChannels).fill(-1);
295+
296+
for (let i = 0; i < numChannels; i++) {
297+
const assetMgr = new TestAssetManager();
298+
const store = {
299+
sessionStateStore,
300+
playheadStateStore,
301+
instanceId: "instance-1",
302+
};
303+
const session = new Session(assetMgr, { sessionId: `concurrent-${i}` }, store);
304+
await session.initAsync();
305+
channels.push(session);
306+
}
307+
308+
for (let round = 0; round < 15; round++) {
309+
const results = await Promise.all(channels.map(ch => ch.incrementAsync()));
310+
311+
for (let i = 0; i < numChannels; i++) {
312+
if (results[i]) {
313+
const m = results[i].match(/#EXT-X-MEDIA-SEQUENCE:(\d+)/);
314+
if (m) {
315+
const mseq = Number(m[1]);
316+
if (lastMseqs[i] >= 0) {
317+
expect(mseq).toBeGreaterThanOrEqual(lastMseqs[i]);
318+
}
319+
lastMseqs[i] = mseq;
320+
}
321+
}
322+
}
323+
}
324+
325+
// All channels should have progressed
326+
for (let i = 0; i < numChannels; i++) {
327+
expect(lastMseqs[i]).toBeGreaterThan(0);
328+
}
329+
});
330+
331+
it("concurrent ticks across a VOD switch produce valid manifests", async () => {
332+
const numChannels = 3;
333+
const channels = [];
334+
335+
for (let i = 0; i < numChannels; i++) {
336+
// Short VOD to force VOD switches
337+
const assetMgr = new TestAssetManager(null, [
338+
{ id: 1, title: "Short", uri: "https://maitv-vod.lab.eyevinn.technology/VINN.mp4/master.m3u8" }
339+
]);
340+
const store = {
341+
sessionStateStore,
342+
playheadStateStore,
343+
instanceId: "instance-1",
344+
};
345+
const session = new Session(assetMgr, { sessionId: `vodswitch-${i}` }, store);
346+
await session.initAsync();
347+
channels.push(session);
348+
}
349+
350+
let errorCount = 0;
351+
for (let round = 0; round < 20; round++) {
352+
const results = await Promise.all(channels.map(ch => ch.incrementAsync()));
353+
for (const manifest of results) {
354+
if (manifest) {
355+
// Verify it's parseable HLS
356+
const hasHeader = manifest.includes('#EXTM3U');
357+
if (!hasHeader) errorCount++;
358+
}
359+
}
360+
}
361+
362+
expect(errorCount).toBe(0);
363+
});
364+
});

0 commit comments

Comments
 (0)