mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 14:12: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:
@@ -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,
|
||||
"tag": "0025_run_queue",
|
||||
"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,
|
||||
// and obviously not one.
|
||||
claimToken: '00000000000000000000000000000000',
|
||||
timeClaimed: '2026-07-28T11:59:30.000Z',
|
||||
timeStarted: '2026-07-28T12:00:00.000Z',
|
||||
timeStopped: null,
|
||||
errorMessage: null
|
||||
errorMessage: null,
|
||||
endReason: null
|
||||
};
|
||||
|
||||
export const GameDownload = {
|
||||
|
||||
@@ -56,6 +56,36 @@ export namespace Session {
|
||||
*/
|
||||
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
|
||||
.object({
|
||||
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',
|
||||
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({
|
||||
description: 'When the box actually started, not when the row appeared',
|
||||
example: Examples.Session.timeStarted
|
||||
@@ -109,6 +144,11 @@ export namespace Session {
|
||||
description: 'Why it failed, when it did',
|
||||
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({
|
||||
description:
|
||||
'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
|
||||
* 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(
|
||||
Info.pick({ id: true, state: true, errorMessage: true }),
|
||||
Info.pick({ id: true, state: true, errorMessage: true, endReason: true }),
|
||||
async (input) => {
|
||||
const now = sql`now()`;
|
||||
return Database.use(async (tx) => {
|
||||
@@ -273,6 +314,10 @@ export namespace Session {
|
||||
.set({
|
||||
state: input.state,
|
||||
errorMessage: input.state === 'failed' ? (input.errorMessage ?? null) : null,
|
||||
endReason:
|
||||
input.state === 'ended' || input.state === 'failed'
|
||||
? (input.endReason ?? null)
|
||||
: null,
|
||||
...(input.state === 'live'
|
||||
? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` }
|
||||
: {}),
|
||||
@@ -325,10 +370,14 @@ export namespace Session {
|
||||
* the only mutual exclusion in this design. `ended` and `failed` are
|
||||
* terminal, so their entries are empty rather than absent — a state with no
|
||||
* 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'][]> = {
|
||||
requested: ['starting'],
|
||||
starting: ['live', 'failed'],
|
||||
starting: ['live', 'ended', 'failed'],
|
||||
live: ['ended', 'failed'],
|
||||
ended: [],
|
||||
failed: []
|
||||
@@ -541,7 +590,8 @@ export namespace Session {
|
||||
.set({
|
||||
state: 'ended',
|
||||
timeStopped: sql`now()`,
|
||||
errorMessage: 'Left the queue'
|
||||
errorMessage: 'Left the queue',
|
||||
endReason: 'left_queue' satisfies EndReason
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
@@ -712,7 +762,12 @@ export namespace Session {
|
||||
// the same moment wins or loses cleanly, never both.
|
||||
const [cancelled] = await tx
|
||||
.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')))
|
||||
.returning();
|
||||
if (cancelled) return serialize(cancelled);
|
||||
@@ -756,7 +811,8 @@ export namespace Session {
|
||||
from: z.enum(SessionState.enumValues),
|
||||
to: z.enum(SessionState.enumValues),
|
||||
claimToken: ClaimToken,
|
||||
errorMessage: Info.shape.errorMessage
|
||||
errorMessage: Info.shape.errorMessage,
|
||||
endReason: Info.shape.endReason
|
||||
}),
|
||||
async (input) => {
|
||||
const now = sql`now()`;
|
||||
@@ -772,9 +828,16 @@ export namespace Session {
|
||||
state: input.to,
|
||||
// Taken on the claim and never written again: a terminal row
|
||||
// still records which attempt ran it, and a holder that goes
|
||||
// back to null lets a settled claim be replayed.
|
||||
...(isClaim ? { claimToken: input.claimToken } : {}),
|
||||
// back to null lets a settled claim be replayed. The time is
|
||||
// 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,
|
||||
// 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'
|
||||
? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` }
|
||||
: {}),
|
||||
@@ -889,7 +952,8 @@ export namespace Session {
|
||||
machineId: z.string(),
|
||||
state: z.enum(SessionState.enumValues),
|
||||
claimToken: ClaimToken,
|
||||
errorMessage: Info.shape.errorMessage
|
||||
errorMessage: Info.shape.errorMessage,
|
||||
endReason: Info.shape.endReason
|
||||
}),
|
||||
async (input): Promise<TransitionResult> => {
|
||||
// One transaction, because "this run is live" and "the box under it
|
||||
@@ -933,7 +997,8 @@ export namespace Session {
|
||||
from: current.state,
|
||||
to: input.state,
|
||||
claimToken: input.claimToken,
|
||||
errorMessage: input.errorMessage
|
||||
errorMessage: input.errorMessage,
|
||||
endReason: input.endReason
|
||||
});
|
||||
// The state read above is not the state written below, and the
|
||||
// gap is where two agents race. Nothing moved means somebody
|
||||
@@ -1084,11 +1149,13 @@ export namespace Session {
|
||||
state: input.state as Info['state'],
|
||||
tier: input.tier as Info['tier'],
|
||||
ticket: input.ticket,
|
||||
timeClaimed: input.timeClaimed?.toISOString() ?? null,
|
||||
timeStarted: input.timeStarted?.toISOString() ?? null,
|
||||
timeStopped: input.timeStopped?.toISOString() ?? null,
|
||||
timeStopRequested: input.timeStopRequested?.toISOString() ?? null,
|
||||
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),
|
||||
/** Why it ended badly, when it did. */
|
||||
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
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
|
||||
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