mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 22:22:39 +02:00
feat(session): a finished session says why it ended (#412)
Until now every finished run said only `ended` or `failed`. A user stop, a trial window running out, an idle timeout, a host restart and a game quitting were indistinguishable afterwards. **Schema.** Migration `0026_session_end_reason` adds two nullable columns to `session`: - `end_reason`: one of `user`, `window`, `hours`, `idle`, `left_queue`, `game_exited`, `launch_failed`, `host_stopped`, `host_restart`. - `time_claimed`: when a host took the run, set on `requested → starting`. **API.** - `POST /session/:id/state` takes an optional `endReason`. With `ended` or `failed` it is stored. With `starting` or `live` it is a 400 and the run does not move. - Leaving it out of a terminal report stores null, so host agents that predate this keep working. - `endReason` and `timeClaimed` are returned on every session response. - The API writes the reasons it decides itself: `user` when a run nobody has claimed is stopped, and `left_queue` when a waiting run is abandoned (the `Left the queue` message is kept). **New transition, `starting → ended`.** A run stopped while its game is still downloading has failed at nothing, and never reached `live`, which is where billing starts. Until now the only way out of `starting` was `failed`. **Release order: this goes first.** The host agent that sends `endReason` is a separate change. This route's body schema is strict, so without this deployed the current API answers that agent with a 400. **Tests.** - API: 185 pass, 5 of them new. - Core: 41 session tests pass, 3 of them new. - The new tests fail when the implementation is broken on purpose. - No new type errors.
This commit is contained in:
@@ -88,10 +88,18 @@ export namespace SessionApi {
|
|||||||
errorMessage: z.string().max(1024).nullable().optional().meta({
|
errorMessage: z.string().max(1024).nullable().optional().meta({
|
||||||
description: 'Why it failed. Kept only for a run that did',
|
description: 'Why it failed. Kept only for a run that did',
|
||||||
example: Examples.Session.errorMessage
|
example: Examples.Session.errorMessage
|
||||||
|
}),
|
||||||
|
endReason: Session.EndReason.nullable().optional().meta({
|
||||||
|
description:
|
||||||
|
'Why the run is over. Only with `ended` or `failed`; leave it out and the run records no reason',
|
||||||
|
example: 'game_exited'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
/** The states a run is over in, and so the only ones a reason goes with. */
|
||||||
|
const TERMINAL: readonly Session.ReportableState[] = ['ended', 'failed'];
|
||||||
|
|
||||||
export const route = new Hono()
|
export const route = new Hono()
|
||||||
.get(
|
.get(
|
||||||
'/',
|
'/',
|
||||||
@@ -385,7 +393,7 @@ export namespace SessionApi {
|
|||||||
tags: ['Session'],
|
tags: ['Session'],
|
||||||
summary: 'Report where a run has got to',
|
summary: 'Report where a run has got to',
|
||||||
description:
|
description:
|
||||||
'For the host the run’s box is placed on, and no other. Moving a run out of `requested` is the claim, and it is a compare-and-set: exactly one caller can take a given run, and one that loses gets 409. Re-reporting a state already reported is fine and changes nothing, including the timestamps a run is billed on. A transition that does not exist is 409 and the run does not move.',
|
'For the host the run’s box is placed on, and no other. Moving a run out of `requested` is the claim, and it is a compare-and-set: exactly one caller can take a given run, and one that loses gets 409. Re-reporting a state already reported is fine and changes nothing, including the timestamps a run is billed on. A transition that does not exist is 409 and the run does not move. Say why a run ended with `endReason` on the report that ends it; sending one with any other state is 400.',
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
content: { 'application/json': { schema: Result(Session.Info) } },
|
content: { 'application/json': { schema: Result(Session.Info) } },
|
||||||
@@ -400,12 +408,24 @@ export namespace SessionApi {
|
|||||||
validator('json', StateReport),
|
validator('json', StateReport),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = c.req.valid('json');
|
const body = c.req.valid('json');
|
||||||
|
// Refused rather than dropped. A reason on a run that is still
|
||||||
|
// going is a host agent that has its states and its reasons out
|
||||||
|
// of step, and storing nothing would hide that from it.
|
||||||
|
if (body.endReason && !TERMINAL.includes(body.state)) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'validation',
|
||||||
|
ErrorCodes.Validation.INVALID_PARAMETER,
|
||||||
|
`A run that is ${body.state} is not over, so it has no reason to have ended`,
|
||||||
|
'endReason'
|
||||||
|
);
|
||||||
|
}
|
||||||
const result = await Session.transition({
|
const result = await Session.transition({
|
||||||
id: c.req.valid('param').id,
|
id: c.req.valid('param').id,
|
||||||
machineId: Actor.machineID,
|
machineId: Actor.machineID,
|
||||||
state: body.state,
|
state: body.state,
|
||||||
claimToken: body.claimToken,
|
claimToken: body.claimToken,
|
||||||
errorMessage: body.errorMessage ?? null
|
errorMessage: body.errorMessage ?? null,
|
||||||
|
endReason: body.endReason ?? null
|
||||||
});
|
});
|
||||||
|
|
||||||
switch (result.outcome) {
|
switch (result.outcome) {
|
||||||
|
|||||||
@@ -146,7 +146,11 @@ describe('the line for a full host', () => {
|
|||||||
await Session.setState({ id: playing.run.id, state: 'ended' });
|
await Session.setState({ id: playing.run.id, state: 'ended' });
|
||||||
expect((await h.jobs()).map((j) => j.sessionId)).toEqual([here.run.id]);
|
expect((await h.jobs()).map((j) => j.sessionId)).toEqual([here.run.id]);
|
||||||
const left = await Session.fromID(gone.run.id);
|
const left = await Session.fromID(gone.run.id);
|
||||||
expect(left).toMatchObject({ state: 'ended', errorMessage: 'Left the queue' });
|
expect(left).toMatchObject({
|
||||||
|
state: 'ended',
|
||||||
|
errorMessage: 'Left the queue',
|
||||||
|
endReason: 'left_queue'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a run with a slot is not in line', async () => {
|
test('a run with a slot is not in line', async () => {
|
||||||
|
|||||||
@@ -160,11 +160,13 @@ describe('POST /session', () => {
|
|||||||
state: 'requested',
|
state: 'requested',
|
||||||
tier: null,
|
tier: null,
|
||||||
ticket: null,
|
ticket: null,
|
||||||
|
timeClaimed: null,
|
||||||
timeStarted: null,
|
timeStarted: null,
|
||||||
timeStopped: null,
|
timeStopped: null,
|
||||||
timeStopRequested: null,
|
timeStopRequested: null,
|
||||||
trial: false,
|
trial: false,
|
||||||
errorMessage: null
|
errorMessage: null,
|
||||||
|
endReason: null
|
||||||
});
|
});
|
||||||
expect(body.data.id.startsWith('ses_')).toBe(true);
|
expect(body.data.id.startsWith('ses_')).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -478,7 +480,11 @@ describe('POST /session/:id/state', () => {
|
|||||||
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
|
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(((await res.json()) as any).data.state).toBe('starting');
|
const claimed = ((await res.json()) as any).data;
|
||||||
|
expect(claimed.state).toBe('starting');
|
||||||
|
// The time it was taken is what bring-up is measured from.
|
||||||
|
expect(body.data.timeClaimed).toBeNull();
|
||||||
|
expect(claimed.timeClaimed).not.toBeNull();
|
||||||
|
|
||||||
const jobs = await app.request('/machine/jobs', { headers: s.host });
|
const jobs = await app.request('/machine/jobs', { headers: s.host });
|
||||||
expect(((await jobs.json()) as any).data).toEqual([]);
|
expect(((await jobs.json()) as any).data).toEqual([]);
|
||||||
@@ -638,6 +644,106 @@ describe('POST /session/:id/state', () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the report that ends a run says why, and the owner reads it', async () => {
|
||||||
|
const s = await scene('route-reason-ended', 5570);
|
||||||
|
const { body } = await requestSession(s);
|
||||||
|
const report = (state: string, endReason?: string) =>
|
||||||
|
app.request(`/session/${body.data.id}/state`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: s.host,
|
||||||
|
body: JSON.stringify({ state, claimToken: HOLDER, endReason })
|
||||||
|
});
|
||||||
|
|
||||||
|
await report('starting');
|
||||||
|
await report('live');
|
||||||
|
const ended = await report('ended', 'game_exited');
|
||||||
|
expect(ended.status).toBe(200);
|
||||||
|
expect(((await ended.json()) as any).data.endReason).toBe('game_exited');
|
||||||
|
|
||||||
|
const read = await app.request(`/session/${body.data.id}`, { headers: s.user });
|
||||||
|
expect(((await read.json()) as any).data.endReason).toBe('game_exited');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a run that failed says why it is over, beside what went wrong', async () => {
|
||||||
|
const s = await scene('route-reason-failed', 5571);
|
||||||
|
const { body } = await requestSession(s);
|
||||||
|
const report = (payload: Record<string, unknown>) =>
|
||||||
|
app.request(`/session/${body.data.id}/state`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: s.host,
|
||||||
|
body: JSON.stringify({ claimToken: HOLDER, ...payload })
|
||||||
|
});
|
||||||
|
|
||||||
|
await report({ state: 'starting' });
|
||||||
|
const failed = await report({
|
||||||
|
state: 'failed',
|
||||||
|
errorMessage: 'the download could not finish',
|
||||||
|
endReason: 'launch_failed'
|
||||||
|
});
|
||||||
|
expect(failed.status).toBe(200);
|
||||||
|
expect(((await failed.json()) as any).data).toMatchObject({
|
||||||
|
state: 'failed',
|
||||||
|
errorMessage: 'the download could not finish',
|
||||||
|
endReason: 'launch_failed'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reason with a run that is not over is refused, and the run stays put', async () => {
|
||||||
|
const s = await scene('route-reason-early', 5572);
|
||||||
|
const { body } = await requestSession(s);
|
||||||
|
await app.request(`/session/${body.data.id}/state`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: s.host,
|
||||||
|
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request(`/session/${body.data.id}/state`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: s.host,
|
||||||
|
body: JSON.stringify({ state: 'live', claimToken: HOLDER, endReason: 'idle' })
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(((await res.json()) as any).param).toBe('endReason');
|
||||||
|
const run = await Session.fromID(body.data.id);
|
||||||
|
expect(run?.state).toBe('starting');
|
||||||
|
expect(run?.endReason).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reason nobody defined is a validation error', async () => {
|
||||||
|
const s = await scene('route-reason-bogus', 5573);
|
||||||
|
const { body } = await requestSession(s);
|
||||||
|
await app.request(`/session/${body.data.id}/state`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: s.host,
|
||||||
|
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
|
||||||
|
});
|
||||||
|
const res = await app.request(`/session/${body.data.id}/state`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: s.host,
|
||||||
|
body: JSON.stringify({ state: 'failed', claimToken: HOLDER, endReason: 'bored' })
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect((await Session.fromID(body.data.id))?.state).toBe('starting');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a host agent that sends no reason still ends the run, with none recorded', async () => {
|
||||||
|
const s = await scene('route-reason-absent', 5574);
|
||||||
|
const { body } = await requestSession(s);
|
||||||
|
const report = (state: string) =>
|
||||||
|
app.request(`/session/${body.data.id}/state`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: s.host,
|
||||||
|
body: JSON.stringify({ state, claimToken: HOLDER })
|
||||||
|
});
|
||||||
|
|
||||||
|
await report('starting');
|
||||||
|
await report('live');
|
||||||
|
// The report every host agent already in the field sends.
|
||||||
|
const ended = await report('ended');
|
||||||
|
expect(ended.status).toBe(200);
|
||||||
|
expect(((await ended.json()) as any).data).toMatchObject({ state: 'ended', endReason: null });
|
||||||
|
});
|
||||||
|
|
||||||
test('a person cannot report a state on their own session', async () => {
|
test('a person cannot report a state on their own session', async () => {
|
||||||
const s = await scene('route-claim-person', 5538);
|
const s = await scene('route-claim-person', 5538);
|
||||||
const { body } = await requestSession(s);
|
const { body } = await requestSession(s);
|
||||||
@@ -721,6 +827,7 @@ describe('POST /session/:id/ticket', () => {
|
|||||||
const data = ((await read.json()) as any).data;
|
const data = ((await read.json()) as any).data;
|
||||||
expect(Object.keys(data).sort()).toEqual([
|
expect(Object.keys(data).sort()).toEqual([
|
||||||
'boxId',
|
'boxId',
|
||||||
|
'endReason',
|
||||||
'errorMessage',
|
'errorMessage',
|
||||||
'gameId',
|
'gameId',
|
||||||
'id',
|
'id',
|
||||||
@@ -729,6 +836,7 @@ describe('POST /session/:id/ticket', () => {
|
|||||||
'state',
|
'state',
|
||||||
'ticket',
|
'ticket',
|
||||||
'tier',
|
'tier',
|
||||||
|
'timeClaimed',
|
||||||
'timeStarted',
|
'timeStarted',
|
||||||
'timeStopRequested',
|
'timeStopRequested',
|
||||||
'timeStopped',
|
'timeStopped',
|
||||||
@@ -901,6 +1009,9 @@ describe('POST /session/:id/stop', () => {
|
|||||||
const out = (await res.json()) as any;
|
const out = (await res.json()) as any;
|
||||||
expect(out.data.state).toBe('ended');
|
expect(out.data.state).toBe('ended');
|
||||||
expect(out.data.timeStopped).not.toBeNull();
|
expect(out.data.timeStopped).not.toBeNull();
|
||||||
|
// Ended here rather than by a host, so the reason is written here too.
|
||||||
|
expect(out.data.endReason).toBe('user');
|
||||||
|
expect(out.data.timeClaimed).toBeNull();
|
||||||
const res2 = await app.request('/machine/jobs', { headers: s.host });
|
const res2 = await app.request('/machine/jobs', { headers: s.host });
|
||||||
expect(((await res2.json()) as any).data).toEqual([]);
|
expect(((await res2.json()) as any).data).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "session" ADD COLUMN "end_reason" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "session" ADD COLUMN "time_claimed" timestamp with time zone;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -183,6 +183,13 @@
|
|||||||
"when": 1791715304549,
|
"when": 1791715304549,
|
||||||
"tag": "0025_run_queue",
|
"tag": "0025_run_queue",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792095717042,
|
||||||
|
"tag": "0026_session_end_reason",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -177,9 +177,11 @@ export namespace Examples {
|
|||||||
// Shaped like the real thing so the docs do not teach a shorter one,
|
// Shaped like the real thing so the docs do not teach a shorter one,
|
||||||
// and obviously not one.
|
// and obviously not one.
|
||||||
claimToken: '00000000000000000000000000000000',
|
claimToken: '00000000000000000000000000000000',
|
||||||
|
timeClaimed: '2026-07-28T11:59:30.000Z',
|
||||||
timeStarted: '2026-07-28T12:00:00.000Z',
|
timeStarted: '2026-07-28T12:00:00.000Z',
|
||||||
timeStopped: null,
|
timeStopped: null,
|
||||||
errorMessage: null
|
errorMessage: null,
|
||||||
|
endReason: null
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GameDownload = {
|
export const GameDownload = {
|
||||||
|
|||||||
@@ -56,6 +56,36 @@ export namespace Session {
|
|||||||
*/
|
*/
|
||||||
export const ABANDONED_AFTER_SECONDS = 60;
|
export const ABANDONED_AFTER_SECONDS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a run is over.
|
||||||
|
*
|
||||||
|
* `ended` and `failed` say how a run finished, not why, and a stop its
|
||||||
|
* owner asked for, a free weekend running out and a game quitting on its
|
||||||
|
* own all read `ended` without this. Whoever ends the run writes it: the
|
||||||
|
* control plane for the two endings it decides itself, `user` on a run
|
||||||
|
* nobody had claimed and `left_queue`, and the host agent for everything
|
||||||
|
* else, in its terminal report. ref(d-0083)
|
||||||
|
*/
|
||||||
|
export const EndReason = z
|
||||||
|
.enum([
|
||||||
|
'user',
|
||||||
|
'window',
|
||||||
|
'hours',
|
||||||
|
'idle',
|
||||||
|
'left_queue',
|
||||||
|
'game_exited',
|
||||||
|
'launch_failed',
|
||||||
|
'host_stopped',
|
||||||
|
'host_restart'
|
||||||
|
])
|
||||||
|
.meta({
|
||||||
|
description:
|
||||||
|
'Why a run is over. `user`: its owner asked. `window`: the free weekend closed. `hours`: this weekend’s free hours are used. `idle`: nobody was connected to it. `left_queue`: it waited for a slot and nobody asked after it. `game_exited`: the game quit on its own. `launch_failed`: it never got as far as playing. `host_stopped`: its host ended it for a reason of its own. `host_restart`: its host restarted and the run did not survive it',
|
||||||
|
example: 'user'
|
||||||
|
});
|
||||||
|
|
||||||
|
export type EndReason = z.infer<typeof EndReason>;
|
||||||
|
|
||||||
export const Info = z
|
export const Info = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string().meta({
|
id: z.string().meta({
|
||||||
@@ -87,6 +117,11 @@ export namespace Session {
|
|||||||
description: 'The size this run was asked for. Null runs at the size of its box',
|
description: 'The size this run was asked for. Null runs at the size of its box',
|
||||||
example: 'md'
|
example: 'md'
|
||||||
}),
|
}),
|
||||||
|
timeClaimed: z.string().nullable().optional().meta({
|
||||||
|
description:
|
||||||
|
'When its host took the run on. Null while it waits for one, and for a run that never had one',
|
||||||
|
example: Examples.Session.timeClaimed
|
||||||
|
}),
|
||||||
timeStarted: z.string().nullable().optional().meta({
|
timeStarted: z.string().nullable().optional().meta({
|
||||||
description: 'When the box actually started, not when the row appeared',
|
description: 'When the box actually started, not when the row appeared',
|
||||||
example: Examples.Session.timeStarted
|
example: Examples.Session.timeStarted
|
||||||
@@ -109,6 +144,11 @@ export namespace Session {
|
|||||||
description: 'Why it failed, when it did',
|
description: 'Why it failed, when it did',
|
||||||
example: Examples.Session.errorMessage
|
example: Examples.Session.errorMessage
|
||||||
}),
|
}),
|
||||||
|
endReason: EndReason.nullable().optional().meta({
|
||||||
|
description:
|
||||||
|
'Why this run is over, once it is. Null while it runs, and for a run whose host did not say',
|
||||||
|
example: Examples.Session.endReason
|
||||||
|
}),
|
||||||
queue: Queue.nullable().optional().meta({
|
queue: Queue.nullable().optional().meta({
|
||||||
description:
|
description:
|
||||||
'Where this run is in line for its host, while every slot there is taken. Null once it is not waiting'
|
'Where this run is in line for its host, while every slot there is taken. Null once it is not waiting'
|
||||||
@@ -261,10 +301,11 @@ export namespace Session {
|
|||||||
*
|
*
|
||||||
* `live` stamps `timeStarted` and `ended`/`failed` stamp `timeStopped`, both
|
* `live` stamps `timeStarted` and `ended`/`failed` stamp `timeStopped`, both
|
||||||
* only if unset — so a duplicate report does not extend a session someone is
|
* only if unset — so a duplicate report does not extend a session someone is
|
||||||
* billed for, and metering can trust the pair.
|
* billed for, and metering can trust the pair. A reason is kept only for a
|
||||||
|
* run that is over, as an error message is kept only for one that failed.
|
||||||
*/
|
*/
|
||||||
export const setState = fn(
|
export const setState = fn(
|
||||||
Info.pick({ id: true, state: true, errorMessage: true }),
|
Info.pick({ id: true, state: true, errorMessage: true, endReason: true }),
|
||||||
async (input) => {
|
async (input) => {
|
||||||
const now = sql`now()`;
|
const now = sql`now()`;
|
||||||
return Database.use(async (tx) => {
|
return Database.use(async (tx) => {
|
||||||
@@ -273,6 +314,10 @@ export namespace Session {
|
|||||||
.set({
|
.set({
|
||||||
state: input.state,
|
state: input.state,
|
||||||
errorMessage: input.state === 'failed' ? (input.errorMessage ?? null) : null,
|
errorMessage: input.state === 'failed' ? (input.errorMessage ?? null) : null,
|
||||||
|
endReason:
|
||||||
|
input.state === 'ended' || input.state === 'failed'
|
||||||
|
? (input.endReason ?? null)
|
||||||
|
: null,
|
||||||
...(input.state === 'live'
|
...(input.state === 'live'
|
||||||
? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` }
|
? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -325,10 +370,14 @@ export namespace Session {
|
|||||||
* the only mutual exclusion in this design. `ended` and `failed` are
|
* the only mutual exclusion in this design. `ended` and `failed` are
|
||||||
* terminal, so their entries are empty rather than absent — a state with no
|
* terminal, so their entries are empty rather than absent — a state with no
|
||||||
* exits is a fact worth writing down.
|
* exits is a fact worth writing down.
|
||||||
|
*
|
||||||
|
* `starting → ended` is a run stopped before it went live — the person
|
||||||
|
* stopping it while its game downloads, say. Nothing failed, so `failed`
|
||||||
|
* would be a lie, and it never reached `live`, so it was never billed.
|
||||||
*/
|
*/
|
||||||
export const NEXT_STATES: Record<Info['state'], readonly Info['state'][]> = {
|
export const NEXT_STATES: Record<Info['state'], readonly Info['state'][]> = {
|
||||||
requested: ['starting'],
|
requested: ['starting'],
|
||||||
starting: ['live', 'failed'],
|
starting: ['live', 'ended', 'failed'],
|
||||||
live: ['ended', 'failed'],
|
live: ['ended', 'failed'],
|
||||||
ended: [],
|
ended: [],
|
||||||
failed: []
|
failed: []
|
||||||
@@ -541,7 +590,8 @@ export namespace Session {
|
|||||||
.set({
|
.set({
|
||||||
state: 'ended',
|
state: 'ended',
|
||||||
timeStopped: sql`now()`,
|
timeStopped: sql`now()`,
|
||||||
errorMessage: 'Left the queue'
|
errorMessage: 'Left the queue',
|
||||||
|
endReason: 'left_queue' satisfies EndReason
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -712,7 +762,12 @@ export namespace Session {
|
|||||||
// the same moment wins or loses cleanly, never both.
|
// the same moment wins or loses cleanly, never both.
|
||||||
const [cancelled] = await tx
|
const [cancelled] = await tx
|
||||||
.update(SessionTable)
|
.update(SessionTable)
|
||||||
.set({ state: 'ended', timeStopRequested: sql`now()`, timeStopped: sql`now()` })
|
.set({
|
||||||
|
state: 'ended',
|
||||||
|
timeStopRequested: sql`now()`,
|
||||||
|
timeStopped: sql`now()`,
|
||||||
|
endReason: 'user' satisfies EndReason
|
||||||
|
})
|
||||||
.where(and(scope, eq(SessionTable.state, 'requested')))
|
.where(and(scope, eq(SessionTable.state, 'requested')))
|
||||||
.returning();
|
.returning();
|
||||||
if (cancelled) return serialize(cancelled);
|
if (cancelled) return serialize(cancelled);
|
||||||
@@ -756,7 +811,8 @@ export namespace Session {
|
|||||||
from: z.enum(SessionState.enumValues),
|
from: z.enum(SessionState.enumValues),
|
||||||
to: z.enum(SessionState.enumValues),
|
to: z.enum(SessionState.enumValues),
|
||||||
claimToken: ClaimToken,
|
claimToken: ClaimToken,
|
||||||
errorMessage: Info.shape.errorMessage
|
errorMessage: Info.shape.errorMessage,
|
||||||
|
endReason: Info.shape.endReason
|
||||||
}),
|
}),
|
||||||
async (input) => {
|
async (input) => {
|
||||||
const now = sql`now()`;
|
const now = sql`now()`;
|
||||||
@@ -772,9 +828,16 @@ export namespace Session {
|
|||||||
state: input.to,
|
state: input.to,
|
||||||
// Taken on the claim and never written again: a terminal row
|
// Taken on the claim and never written again: a terminal row
|
||||||
// still records which attempt ran it, and a holder that goes
|
// still records which attempt ran it, and a holder that goes
|
||||||
// back to null lets a settled claim be replayed.
|
// back to null lets a settled claim be replayed. The time is
|
||||||
...(isClaim ? { claimToken: input.claimToken } : {}),
|
// taken with it, so a run's bring-up can be measured from
|
||||||
|
// the moment a host had it rather than from when it asked.
|
||||||
|
...(isClaim ? { claimToken: input.claimToken, timeClaimed: now } : {}),
|
||||||
errorMessage: input.to === 'failed' ? (input.errorMessage ?? null) : null,
|
errorMessage: input.to === 'failed' ? (input.errorMessage ?? null) : null,
|
||||||
|
// Only a run that is over has a reason to be over. Null when
|
||||||
|
// the report carries none, which is every report from a
|
||||||
|
// host agent that predates the field.
|
||||||
|
endReason:
|
||||||
|
input.to === 'ended' || input.to === 'failed' ? (input.endReason ?? null) : null,
|
||||||
...(input.to === 'live'
|
...(input.to === 'live'
|
||||||
? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` }
|
? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -889,7 +952,8 @@ export namespace Session {
|
|||||||
machineId: z.string(),
|
machineId: z.string(),
|
||||||
state: z.enum(SessionState.enumValues),
|
state: z.enum(SessionState.enumValues),
|
||||||
claimToken: ClaimToken,
|
claimToken: ClaimToken,
|
||||||
errorMessage: Info.shape.errorMessage
|
errorMessage: Info.shape.errorMessage,
|
||||||
|
endReason: Info.shape.endReason
|
||||||
}),
|
}),
|
||||||
async (input): Promise<TransitionResult> => {
|
async (input): Promise<TransitionResult> => {
|
||||||
// One transaction, because "this run is live" and "the box under it
|
// One transaction, because "this run is live" and "the box under it
|
||||||
@@ -933,7 +997,8 @@ export namespace Session {
|
|||||||
from: current.state,
|
from: current.state,
|
||||||
to: input.state,
|
to: input.state,
|
||||||
claimToken: input.claimToken,
|
claimToken: input.claimToken,
|
||||||
errorMessage: input.errorMessage
|
errorMessage: input.errorMessage,
|
||||||
|
endReason: input.endReason
|
||||||
});
|
});
|
||||||
// The state read above is not the state written below, and the
|
// The state read above is not the state written below, and the
|
||||||
// gap is where two agents race. Nothing moved means somebody
|
// gap is where two agents race. Nothing moved means somebody
|
||||||
@@ -1084,11 +1149,13 @@ export namespace Session {
|
|||||||
state: input.state as Info['state'],
|
state: input.state as Info['state'],
|
||||||
tier: input.tier as Info['tier'],
|
tier: input.tier as Info['tier'],
|
||||||
ticket: input.ticket,
|
ticket: input.ticket,
|
||||||
|
timeClaimed: input.timeClaimed?.toISOString() ?? null,
|
||||||
timeStarted: input.timeStarted?.toISOString() ?? null,
|
timeStarted: input.timeStarted?.toISOString() ?? null,
|
||||||
timeStopped: input.timeStopped?.toISOString() ?? null,
|
timeStopped: input.timeStopped?.toISOString() ?? null,
|
||||||
timeStopRequested: input.timeStopRequested?.toISOString() ?? null,
|
timeStopRequested: input.timeStopRequested?.toISOString() ?? null,
|
||||||
trial: input.trial,
|
trial: input.trial,
|
||||||
errorMessage: input.errorMessage
|
errorMessage: input.errorMessage,
|
||||||
|
endReason: input.endReason as Info['endReason']
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,18 @@ export const SessionTable = pgTable(
|
|||||||
trial: boolean('trial').notNull().default(false),
|
trial: boolean('trial').notNull().default(false),
|
||||||
/** Why it ended badly, when it did. */
|
/** Why it ended badly, when it did. */
|
||||||
errorMessage: text('error_message'),
|
errorMessage: text('error_message'),
|
||||||
|
/**
|
||||||
|
* Why the run is over, in one word from `Session.EndReason`, written by
|
||||||
|
* whoever ended it. Null while it runs, and null for a run ended by a
|
||||||
|
* host agent too old to say. Text rather than a database enum, so a
|
||||||
|
* reason added later is a code change and not a migration.
|
||||||
|
*/
|
||||||
|
endReason: text('end_reason'),
|
||||||
|
/**
|
||||||
|
* When a host agent claimed this run, or null while it waits. The gap
|
||||||
|
* between this and `timeStarted` is the time spent bringing it up.
|
||||||
|
*/
|
||||||
|
timeClaimed: utc('time_claimed'),
|
||||||
/**
|
/**
|
||||||
* Which attempt holds this run. Null until an agent claims it, and never
|
* Which attempt holds this run. Null until an agent claims it, and never
|
||||||
* cleared afterwards — a terminal row still records who ran it, and a
|
* cleared afterwards — a terminal row still records who ran it, and a
|
||||||
|
|||||||
@@ -1157,3 +1157,60 @@ describe('Session tickets need a claim first', () => {
|
|||||||
expect(closed.outcome).toBe('closed');
|
expect(closed.outcome).toBe('closed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Session end reasons', () => {
|
||||||
|
const report = (
|
||||||
|
run: { machineId: string; session: { id: string } },
|
||||||
|
state: Session.ReportableState,
|
||||||
|
endReason: Session.EndReason | null = null
|
||||||
|
) =>
|
||||||
|
Session.transition({
|
||||||
|
claimToken: HOLDER,
|
||||||
|
id: run.session.id,
|
||||||
|
machineId: run.machineId,
|
||||||
|
state,
|
||||||
|
errorMessage: null,
|
||||||
|
endReason
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the claim stamps when a host took the run, and nothing before it does', async () => {
|
||||||
|
const run = await requestedRun('ses-claimed-at', 5470);
|
||||||
|
expect(run.session.timeClaimed).toBeNull();
|
||||||
|
|
||||||
|
const claimed = await report(run, 'starting');
|
||||||
|
expect(claimed.session?.timeClaimed).not.toBeNull();
|
||||||
|
|
||||||
|
// Going live is not a second claim, so the time a host took the run
|
||||||
|
// stays the time it took it.
|
||||||
|
const live = await report(run, 'live');
|
||||||
|
expect(live.session?.timeClaimed).toBe(claimed.session!.timeClaimed!);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reason is kept only for a run that is over', async () => {
|
||||||
|
const run = await requestedRun('ses-reason-terminal', 5471);
|
||||||
|
await report(run, 'starting');
|
||||||
|
|
||||||
|
// The write itself refuses to give a running run a reason, whoever
|
||||||
|
// calls it, so no row can say it ended while it is still going.
|
||||||
|
const live = await report(run, 'live', 'idle');
|
||||||
|
expect(live.outcome).toBe('moved');
|
||||||
|
expect(live.session?.endReason).toBeNull();
|
||||||
|
|
||||||
|
const ended = await report(run, 'ended', 'idle');
|
||||||
|
expect(ended.session?.endReason).toBe('idle');
|
||||||
|
expect((await Session.fromID(run.session.id))?.endReason).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a run stopped before it went live ends, with its reason, and was never billed', async () => {
|
||||||
|
const run = await requestedRun('ses-stopped-starting', 5472);
|
||||||
|
await report(run, 'starting');
|
||||||
|
|
||||||
|
// Stopped while its game downloaded: nothing failed, and it never
|
||||||
|
// reached `live`, which is where billing starts.
|
||||||
|
const ended = await report(run, 'ended', 'user');
|
||||||
|
expect(ended.outcome).toBe('moved');
|
||||||
|
expect(ended.session?.state).toBe('ended');
|
||||||
|
expect(ended.session?.endReason).toBe('user');
|
||||||
|
expect(ended.session?.timeStarted).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user