diff --git a/src/matches/clips/clips.service.spec.ts b/src/matches/clips/clips.service.spec.ts index 3f2ce76eb..f63aa8350 100644 --- a/src/matches/clips/clips.service.spec.ts +++ b/src/matches/clips/clips.service.spec.ts @@ -183,4 +183,81 @@ describe("ClipsService", () => { expect(batchQueue.add).toHaveBeenCalledTimes(2); }); }); + describe("reportClipRenderStatus", () => { + const setOf = () => + hasura.mutation.mock.calls[0][0].update_clip_render_jobs_by_pk.__args + ._set; + + beforeEach(() => { + hasura.mutation.mockResolvedValue({ + update_clip_render_jobs_by_pk: { id: "job-1" }, + }); + }); + + it("moves the row for a real status", async () => { + hasura.query.mockResolvedValueOnce({ + clip_render_jobs_by_pk: { status: "queued", status_history: [] }, + }); + + await service.reportClipRenderStatus("job-1", { + status: "rendering", + progress: 0.5, + }); + + const set = setOf(); + expect(set.status).toBe("rendering"); + expect(set.progress).toBe(0.5); + }); + + it("keeps the row queued for a boot tick", async () => { + hasura.query.mockResolvedValueOnce({ + clip_render_jobs_by_pk: { status: "queued", status_history: [] }, + }); + + await service.reportClipRenderStatus("job-1", { + status: "booting", + boot_stage: "downloading_cs2:Verifying", + boot_progress: 0.42, + }); + + const set = setOf(); + expect(set.status).toBeUndefined(); + const history = set.status_history as any[]; + expect(history[0].status).toBe("booting"); + expect(history[0].boot_stage).toBe("downloading_cs2:Verifying"); + expect(history[0].boot_progress).toBe(0.42); + }); + + it("files an event as a boot stage instead of an off-enum status", async () => { + // clip_render_jobs_status_chk only allows the six pipeline statuses, so + // a raw `demo_ready` in `status` would fail the mutation outright. + hasura.query.mockResolvedValueOnce({ + clip_render_jobs_by_pk: { status: "queued", status_history: [] }, + }); + + await service.reportClipRenderStatus("job-1", { + status: "demo_ready", + event: "1", + }); + + const set = setOf(); + expect(set.status).toBeUndefined(); + const history = set.status_history as any[]; + expect(history[0].status).toBe("booting"); + expect(history[0].boot_stage).toBe("demo_ready"); + }); + + it("ignores event=0", async () => { + hasura.query.mockResolvedValueOnce({ + clip_render_jobs_by_pk: { status: "queued", status_history: [] }, + }); + + await service.reportClipRenderStatus("job-1", { + status: "rendering", + event: "0", + }); + + expect(setOf().status).toBe("rendering"); + }); + }); }); diff --git a/src/matches/clips/clips.service.ts b/src/matches/clips/clips.service.ts index 6ecadcb42..26e34c920 100644 --- a/src/matches/clips/clips.service.ts +++ b/src/matches/clips/clips.service.ts @@ -1441,8 +1441,11 @@ export class ClipsService { } // Boot ticks land in status_history without overwriting `status` — - // IN_FLIGHT_STATUSES filtering depends on the row status staying queued. - const isBoot = body.status === "booting"; + // IN_FLIGHT_STATUSES filtering depends on the row status staying queued, + // and clip_render_jobs_status_chk would reject anything off-enum anyway. + const isEvent = GameStreamerService.isTruthyFlag(body.event); + const isBoot = body.status === "booting" || isEvent; + const bootStage = isEvent ? body.status : body.boot_stage; const prevHistory = current.status_history as | Array<{ @@ -1455,12 +1458,12 @@ export class ClipsService { | undefined; const nextHistory = Array.isArray(prevHistory) ? [...prevHistory] : []; const entry: Record = { - status: body.status, + status: isBoot ? "booting" : body.status, at: new Date().toISOString(), }; if (isBoot) { - if (typeof body.boot_stage === "string" && body.boot_stage.length > 0) { - entry.boot_stage = body.boot_stage.slice(0, 64); + if (typeof bootStage === "string" && bootStage.length > 0) { + entry.boot_stage = bootStage.slice(0, 64); } if ( typeof body.boot_progress === "number" && diff --git a/src/matches/clips/types/ClipRenderStatusDto.ts b/src/matches/clips/types/ClipRenderStatusDto.ts index 3aab2a226..0a98b0a3f 100644 --- a/src/matches/clips/types/ClipRenderStatusDto.ts +++ b/src/matches/clips/types/ClipRenderStatusDto.ts @@ -6,4 +6,8 @@ export interface ClipRenderStatusDto { // Sent with status="booting" — written to status_history only. boot_stage?: string; boot_progress?: number; + // One-shot milestone (e.g. demo_ready) raised alongside the boot. Batch + // pods already fold these into status="booting" on the wire; honouring the + // flag here keeps a direct post from putting an off-enum value in `status`. + event?: boolean | string; } diff --git a/src/matches/game-streamer/game-streamer.service.spec.ts b/src/matches/game-streamer/game-streamer.service.spec.ts new file mode 100644 index 000000000..d1361eccd --- /dev/null +++ b/src/matches/game-streamer/game-streamer.service.spec.ts @@ -0,0 +1,230 @@ +jest.mock("@kubernetes/client-node", () => ({ + BatchV1Api: class BatchV1Api {}, + CoreV1Api: class CoreV1Api {}, + KubeConfig: class KubeConfig {}, + Exec: class Exec {}, +})); + +import { GameStreamerService } from "./game-streamer.service"; + +describe("GameStreamerService", () => { + let service: GameStreamerService; + let hasura: { query: jest.Mock; mutation: jest.Mock }; + let logger: { log: jest.Mock; warn: jest.Mock; error: jest.Mock }; + + const config = { + get: (key: string) => + key === "gameServers" ? { namespace: "test" } : ({} as any), + }; + + beforeEach(() => { + hasura = { query: jest.fn(), mutation: jest.fn() }; + logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() }; + + service = new GameStreamerService( + logger as any, + config as any, + hasura as any, + {} as any, + { getConnection: jest.fn() } as any, + {} as any, + {} as any, + {} as any, + ); + }); + + const setOf = () => + hasura.mutation.mock.calls[0][0].update_match_demo_sessions_by_pk.__args + ._set; + + describe("reportDemoStatus", () => { + it("moves the row for a normal status report", async () => { + hasura.query.mockResolvedValueOnce({ + match_demo_sessions_by_pk: { + status: "downloading_cs2", + status_history: [{ status: "downloading_cs2", at: "2026-01-01" }], + }, + }); + hasura.mutation.mockResolvedValueOnce({}); + + await service.reportDemoStatus("session-1", { status: "launching_cs2" }); + + const set = setOf(); + expect(set.status).toBe("launching_cs2"); + expect(set.last_status_at).toBe("now()"); + expect(set.status_history).toHaveLength(2); + expect((set.status_history as any[])[1].status).toBe("launching_cs2"); + }); + + it("leaves the row status alone for an event, appending to history", async () => { + hasura.query.mockResolvedValueOnce({ + match_demo_sessions_by_pk: { + status: "downloading_cs2", + status_history: [{ status: "downloading_cs2", at: "2026-01-01" }], + }, + }); + hasura.mutation.mockResolvedValueOnce({}); + + await service.reportDemoStatus("session-1", { + status: "demo_ready", + event: "1", + }); + + const set = setOf(); + expect(set.status).toBeUndefined(); + expect(set.error_message).toBeUndefined(); + expect(set.last_status_at).toBe("now()"); + expect((set.status_history as any[]).map((e: any) => e.status)).toEqual([ + "downloading_cs2", + "demo_ready", + ]); + }); + + it("treats event=0 as a normal status report", async () => { + hasura.query.mockResolvedValueOnce({ + match_demo_sessions_by_pk: { status: "booting", status_history: [] }, + }); + hasura.mutation.mockResolvedValueOnce({}); + + await service.reportDemoStatus("session-1", { + status: "launching_cs2", + event: "0", + }); + + expect(setOf().status).toBe("launching_cs2"); + }); + + it("appends after an event instead of rewriting it", async () => { + // The row still says downloading_cs2 while the newest history entry is + // the demo_ready event — coalescing must compare against the entry. + hasura.query.mockResolvedValueOnce({ + match_demo_sessions_by_pk: { + status: "downloading_cs2", + status_history: [ + { status: "downloading_cs2", at: "2026-01-01T00:00:00.000Z" }, + { status: "demo_ready", at: "2026-01-01T00:01:00.000Z" }, + ], + }, + }); + hasura.mutation.mockResolvedValueOnce({}); + + await service.reportDemoStatus("session-1", { + status: "downloading_cs2", + progress: "42", + }); + + const history = setOf().status_history as any[]; + expect(history.map((e) => e.status)).toEqual([ + "downloading_cs2", + "demo_ready", + "downloading_cs2", + ]); + expect(history[2].progress).toBe(42); + }); + + it("coalesces repeat progress ticks into the last entry", async () => { + hasura.query.mockResolvedValueOnce({ + match_demo_sessions_by_pk: { + status: "downloading_cs2", + status_history: [ + { + status: "downloading_cs2", + at: "2026-01-01T00:00:00.000Z", + progress: 10, + }, + ], + }, + }); + hasura.mutation.mockResolvedValueOnce({}); + + await service.reportDemoStatus("session-1", { + status: "downloading_cs2", + progress: "55", + }); + + const history = setOf().status_history as any[]; + expect(history).toHaveLength(1); + expect(history[0].progress).toBe(55); + // `at` stays the stage start so elapsed doesn't reset every tick. + expect(history[0].at).toBe("2026-01-01T00:00:00.000Z"); + }); + + it("clamps an oversized status before it reaches the row or history", async () => { + hasura.query.mockResolvedValueOnce({ + match_demo_sessions_by_pk: { status: "booting", status_history: [] }, + }); + hasura.mutation.mockResolvedValueOnce({}); + + await service.reportDemoStatus("session-1", { status: "x".repeat(500) }); + + const set = setOf(); + expect(set.status).toHaveLength(64); + expect((set.status_history as any[])[0].status).toHaveLength(64); + }); + + it("no-ops when the session row is gone", async () => { + hasura.query.mockResolvedValueOnce({ match_demo_sessions_by_pk: null }); + + await service.reportDemoStatus("session-1", { status: "live" }); + + expect(hasura.mutation).not.toHaveBeenCalled(); + }); + }); + + describe("reportStatus", () => { + const streamSetOf = () => + hasura.mutation.mock.calls[0][0].update_match_streams.__args._set; + + beforeEach(() => { + hasura.mutation.mockResolvedValue({ + update_match_streams: { affected_rows: 1 }, + }); + }); + + it("keeps stream_url and is_live intact for an event", async () => { + hasura.query.mockResolvedValueOnce({ + match_streams: [{ status: "live", status_history: [] }], + }); + + await service.reportStatus("match-1", { + status: "demo_ready", + event: "1", + }); + + const set = streamSetOf(); + expect(set.status).toBeUndefined(); + expect(set.is_live).toBeUndefined(); + expect(set.stream_url).toBeUndefined(); + expect((set.status_history as any[])[0].status).toBe("demo_ready"); + }); + + it("still takes a stream_url an event carries", async () => { + hasura.query.mockResolvedValueOnce({ + match_streams: [{ status: "booting", status_history: [] }], + }); + + await service.reportStatus("match-1", { + status: "demo_ready", + event: "1", + stream_url: "srt://host?streamid=publish:match-1", + }); + + expect(streamSetOf().stream_url).toBe( + "srt://host?streamid=publish:match-1", + ); + }); + + it("clears stream_url on a normal report that omits it", async () => { + hasura.query.mockResolvedValueOnce({ + match_streams: [{ status: "booting", status_history: [] }], + }); + + await service.reportStatus("match-1", { status: "launching_cs2" }); + + const set = streamSetOf(); + expect(set.status).toBe("launching_cs2"); + expect(set.stream_url).toBeNull(); + expect(set.is_live).toBe(false); + }); + }); +}); diff --git a/src/matches/game-streamer/game-streamer.service.ts b/src/matches/game-streamer/game-streamer.service.ts index ffc5c0e7e..e29085689 100644 --- a/src/matches/game-streamer/game-streamer.service.ts +++ b/src/matches/game-streamer/game-streamer.service.ts @@ -406,12 +406,32 @@ export class GameStreamerService { return trimmed.slice(0, 64); } + // bash sends flags as the strings "1"/"0"; "0" and "false" must not + // read as true the way a bare truthiness check would. + public static isTruthyFlag(raw: unknown): boolean { + if (typeof raw === "boolean") return raw; + if (typeof raw === "number") return raw !== 0; + if (typeof raw !== "string") return false; + const normalized = raw.trim().toLowerCase(); + return normalized === "1" || normalized === "true"; + } + + // `status` is a free-form string off an unauthenticated cluster-internal + // endpoint and lands in a jsonb history capped by entry count, not size. + // Clamp it like progress_stage/boot_stage already are. + private static parseStatus(raw: string): string { + return raw.trim().slice(0, 64); + } + // Builds the next status_history. Status change → append. Same status // with progress → mutate the last entry in place so download ticks // don't blow the cap-50. + // + // Coalescing compares against the LAST HISTORY ENTRY, not the row's + // status: an `event` tick appends without moving the row, so the two + // diverge and comparing against the row would rewrite the event entry. private nextStatusHistory( rawPrevious: unknown, - currentStatus: unknown, newStatus: string, progress: number | null, progress_stage: string | null, @@ -426,10 +446,12 @@ export class GameStreamerService { if (progress !== null) entry.progress = progress; if (progress_stage !== null) entry.progress_stage = progress_stage; - if (currentStatus !== newStatus || previous.length === 0) { + const last = previous[previous.length - 1] as + | Record + | undefined; + if (previous.length === 0 || last?.status !== newStatus) { return [...previous, entry].slice(-STATUS_HISTORY_CAP); } - const last = previous[previous.length - 1] as Record; entry.at = (last?.at as string) ?? entry.at; return [...previous.slice(0, -1), entry]; } @@ -1253,23 +1275,29 @@ export class GameStreamerService { return; } + const status = GameStreamerService.parseStatus(body.status); const progress = this.parseProgress(body.progress); const progress_stage = this.parseProgressStage(body.progress_stage); const nextHistory = this.nextStatusHistory( current.status_history, - current.status, - body.status, + status, progress, progress_stage, ); - const statusChanged = current.status !== body.status; + // An `event` is a one-shot milestone (demo_ready) raised by a worker + // running alongside the main boot — it marks a stage complete in the + // history without yanking the row off whatever setup-steam is doing. + const isEvent = GameStreamerService.isTruthyFlag(body.event); + const statusChanged = current.status !== status; const set: Record = { - status: body.status, - error_message: body.error ?? null, status_history: nextHistory, }; - if (statusChanged) { + if (!isEvent) { + set.status = status; + set.error_message = body.error ?? null; + } + if (isEvent || statusChanged) { set.last_status_at = "now()"; } @@ -1288,7 +1316,7 @@ export class GameStreamerService { ? ` progress=${progress}${progress_stage ? ` stage=${progress_stage}` : ""}` : ""; this.logger.log( - `[demo ${sessionId}] status=${body.status}${progressNote}${body.error ? ` err=${body.error}` : ""}`, + `[demo ${sessionId}] ${isEvent ? "event" : "status"}=${status}${progressNote}${body.error ? ` err=${body.error}` : ""}`, ); } @@ -2741,25 +2769,35 @@ export class GameStreamerService { }, }); const row = match_streams?.[0]; + const status = GameStreamerService.parseStatus(body.status); const progress = this.parseProgress(body.progress); const progress_stage = this.parseProgressStage(body.progress_stage); const nextHistory = this.nextStatusHistory( row?.status_history, - row?.status, - body.status, + status, progress, progress_stage, ); - const statusChanged = row?.status !== body.status; + // See reportDemoStatus: an event only appends to the history. Letting it + // through the normal path would also blank stream_url and is_live, which + // are derived from the body the main boot sends. + const isEvent = GameStreamerService.isTruthyFlag(body.event); + const statusChanged = row?.status !== status; const setClause: Record = { - status: body.status, - stream_url: body.stream_url ?? null, - error_message: body.error ?? null, - is_live: body.status === "live", status_history: nextHistory, }; - if (statusChanged) { + if (!isEvent) { + setClause.status = status; + setClause.stream_url = body.stream_url ?? null; + setClause.error_message = body.error ?? null; + setClause.is_live = status === "live"; + } else if (body.stream_url) { + // run-demo.sh reports the url alongside its first tick, which is an + // event when the demo was already on disk — take it, just never clear it. + setClause.stream_url = body.stream_url; + } + if (isEvent || statusChanged) { setClause.last_status_at = "now()"; } @@ -2782,7 +2820,7 @@ export class GameStreamerService { ? ` progress=${progress}${progress_stage ? ` stage=${progress_stage}` : ""}` : ""; this.logger.log( - `[${matchId}] reportStatus status=${body.status}${progressNote} updated=${updated}`, + `[${matchId}] reportStatus ${isEvent ? "event" : "status"}=${status}${progressNote} updated=${updated}`, ); if (updated === 0) { @@ -2807,6 +2845,9 @@ export class GameStreamerService { link: `${this.appConfig.gameStreamDomain}/${matchId}/`, priority: 0, is_game_streamer: true, + // Seed `status` first: an event omits it from setClause, and a + // brand-new row is better off with the reported stage than null. + status, ...setClause, }, }, @@ -2816,9 +2857,9 @@ export class GameStreamerService { this.logger.log(`[${matchId}] inserted new match_streams row`); } - if (body.status === "live") { + if (status === "live") { this.logger.log(`[${matchId}] "${GAME_STREAMER_TITLE}" → live`); - } else if (body.status === "errored") { + } else if (status === "errored") { this.logger.warn( `[${matchId}] streamer errored: ${body.error ?? ""}`, ); @@ -3090,7 +3131,6 @@ export class GameStreamerService { const nextHistory = this.nextStatusHistory( node?.shader_bake_status_history, - node?.shader_bake_status, status, progress, progress_stage, diff --git a/src/matches/game-streamer/types/GameStreamerStatusDto.ts b/src/matches/game-streamer/types/GameStreamerStatusDto.ts index 258302b7a..2d4914124 100644 --- a/src/matches/game-streamer/types/GameStreamerStatusDto.ts +++ b/src/matches/game-streamer/types/GameStreamerStatusDto.ts @@ -5,4 +5,8 @@ export interface GameStreamerStatusDto { // 0..100; bash sends as string, coerced in the service. progress?: number | string; progress_stage?: string; + // One-shot milestone (e.g. demo_ready) raised by a worker running + // alongside the main boot: appended to status_history only, `status` + // is left on whatever the boot is actually doing. + event?: boolean | string; }