mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
feat(core,api): record burn as rate segments, and refuse the next run when spent
The counters this fills are the ones the windows already knew how to read. What was missing was anything that put a number in them. Burn is recorded as segments: one stretch of one run at one unchanging rate, opened when the rate becomes true and closed when it stops being. Not a row per session, because a session's rate does not survive its own lifetime — a second run changes what the account spends per second while the first is still going, and a rate that applied from that moment must not be backdated over the time before it. Not a row per event either, because burn accrues against an envelope that is held rather than per thing consumed. Closing a segment is what moves burn into the counters, so a long run lands incrementally instead of all at the end. Burn that only arrives when a session stops is burn that cannot refuse the next one, and a bar that does not move while something is running is a bar nobody believes. The counters are written with the staleness rule as a single statement: add to the total if its stamp is still inside the window, otherwise start again from this amount. Reading and then deciding would be two statements with a gap, and the gap is where a concurrent tick doubles or vanishes. The first tick for a team and the thousandth are the same call, for the same reason. The gate sits at the one moment it is allowed to speak — before a run starts, never again. A limit refuses the next run and never interrupts one already going; someone losing a session mid-game to a meter does not come back. Every window is checked rather than the shortest, because they protect different things over different spans. The answer comes back with the created run rather than being thrown away: the response carries where each window stands, what the account spends per second now, and what one more run would cost. Every surface that can start a run has to show that before the click, and a second call for it is a call nobody makes. Asking twice would also let the number shown and the number billed disagree. Accrual is wired to the run's own state transition, in the same transaction that moves it. A session that went live without its meter starting is free hardware; one that ended without its meter stopping bills forever. Both are silent, so neither may be a second write that might not happen.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Actor } from '@nestri/core/actor';
|
||||
import { Billing } from '@nestri/core/billing/index';
|
||||
import { Box } from '@nestri/core/box/index';
|
||||
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
@@ -11,7 +12,14 @@ import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorResponses, machineOnly, notPublic, Result, validator } from '../utils';
|
||||
import {
|
||||
ErrorResponses,
|
||||
machineOnly,
|
||||
notPublic,
|
||||
Result,
|
||||
ResultWithBilling,
|
||||
validator
|
||||
} from '../utils';
|
||||
|
||||
/**
|
||||
* Requesting a run, and carrying one out.
|
||||
@@ -89,17 +97,22 @@ export namespace SessionApi {
|
||||
tags: ['Session'],
|
||||
summary: 'Ask for a run of a box',
|
||||
description:
|
||||
'Creates the run in state `requested`, which is the work order the box’s host picks up. This makes no decision about where the run happens: a box already names the hardware it is placed on, so the run inherits it. Poll the run to watch it start, and re-read its ticket rather than keeping the first one.',
|
||||
'Creates the run in state `requested`, which is the work order the box’s host picks up. This makes no decision about where the run happens: a box already names the hardware it is placed on, so the run inherits it. Poll the run to watch it start, and re-read its ticket rather than keeping the first one. The response carries where the account’s allowance stands and what it is now spending per second, including what one more run would cost — a spent allowance refuses this call with a 429 and never interrupts a run already going.',
|
||||
responses: {
|
||||
201: {
|
||||
content: { 'application/json': { schema: Result(Session.Info) } },
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ResultWithBilling(Session.Info, Billing.State.nullable())
|
||||
}
|
||||
},
|
||||
description: 'The run has been requested'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403],
|
||||
404: ErrorResponses[404],
|
||||
409: ErrorResponses[409]
|
||||
409: ErrorResponses[409],
|
||||
429: ErrorResponses[429]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
@@ -217,13 +230,22 @@ export namespace SessionApi {
|
||||
conflict(Session.BOX_BUSY);
|
||||
}
|
||||
|
||||
// The allowance is checked here and nowhere later. A limit refuses
|
||||
// the next run; it never stops one already going, so this is the
|
||||
// only moment it may speak. The answer comes back rather than
|
||||
// being discarded, because the caller has to be told what it will
|
||||
// cost and what remains — and asking a second time would let the
|
||||
// number shown and the number billed disagree.
|
||||
const team = await Billing.teamForBox(box.id);
|
||||
const billing = team ? await Billing.assertMayStart(team) : null;
|
||||
|
||||
const session = await Session.request({
|
||||
id: Identifier.ascending('session'),
|
||||
boxId: box.id,
|
||||
gameId: game.id,
|
||||
linkedAccountId
|
||||
});
|
||||
return c.json({ data: session }, 201);
|
||||
return c.json({ data: session, billing }, 201);
|
||||
}
|
||||
)
|
||||
.get(
|
||||
|
||||
@@ -4,3 +4,18 @@ import { z } from 'zod';
|
||||
export function Result<T extends z.ZodTypeAny>(schema: T) {
|
||||
return resolver(z.object({ data: schema }));
|
||||
}
|
||||
|
||||
/**
|
||||
* A result that also carries where the caller's allowance stands.
|
||||
*
|
||||
* Every surface that can start a run has to show what it will cost and what
|
||||
* remains, so the answer travels with the thing that spends it rather than
|
||||
* needing a second call nobody will make. It sits beside `data` and not inside
|
||||
* it, because it describes the account rather than the resource.
|
||||
*/
|
||||
export function ResultWithBilling<T extends z.ZodTypeAny, B extends z.ZodTypeAny>(
|
||||
schema: T,
|
||||
billing: B
|
||||
) {
|
||||
return resolver(z.object({ data: schema, billing }));
|
||||
}
|
||||
|
||||
@@ -112,6 +112,13 @@ async function requestSession(s: Awaited<ReturnType<typeof scene>>) {
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdUserIds.length > 0) {
|
||||
// `burn_segment` holds a session with `restrict` — deleting a run must
|
||||
// not erase what it cost — so the record goes before the runs do.
|
||||
await sql`delete from "burn_segment" where session_id in (
|
||||
select s.id from "session" s
|
||||
join "box" b on b.id = s.box_id
|
||||
where b.user_id in ${sql(createdUserIds)}
|
||||
)`;
|
||||
await sql`delete from "box" where user_id in ${sql(createdUserIds)}`;
|
||||
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
|
||||
createdUserIds.length = 0;
|
||||
@@ -131,7 +138,20 @@ describe('POST /session', () => {
|
||||
// The field names are the contract. A rename on either side produces a
|
||||
// host that starts, reads nothing, and reports success — so the shape
|
||||
// is asserted whole rather than field by field.
|
||||
expect(Object.keys(body)).toEqual(['data']);
|
||||
// `billing` rides alongside `data` on purpose: every surface that can
|
||||
// start a run has to show what it costs and what remains, and a second
|
||||
// call for that is a call nobody makes.
|
||||
expect(Object.keys(body)).toEqual(['data', 'billing']);
|
||||
expect(body.billing.exhausted).toBe(false);
|
||||
expect(body.billing.windows.map((w: { window: string }) => w.window)).toEqual([
|
||||
'fiveHour',
|
||||
'sevenDay',
|
||||
'thirtyDay'
|
||||
]);
|
||||
// Nothing is live yet, so nothing is being spent — and one more run
|
||||
// would cost exactly one unit per second.
|
||||
expect(body.billing.rateMilli).toBe(0);
|
||||
expect(body.billing.rateMilliIfOneMore).toBe(1000);
|
||||
expect(body.data).toEqual({
|
||||
id: body.data.id,
|
||||
boxId: s.box.id,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
-- What a team has spent, and the record it is derived from.
|
||||
--
|
||||
-- Two tables because they answer different questions and are written at very
|
||||
-- different rates. `burn_counter` is one row per team holding a running total
|
||||
-- per window; `burn_segment` is the append-only record those totals come from.
|
||||
-- The totals are disposable and can be rebuilt from the record, which is what
|
||||
-- makes zeroing one a support action rather than data loss.
|
||||
--
|
||||
-- **Each total is stored beside the time it began.** A total whose stamp has
|
||||
-- fallen outside its window reads as zero, so a window rolls clear without
|
||||
-- anything running -- no schedule to misfire, and no race between a reset and a
|
||||
-- write arriving together. The same rule on the way in is a single statement:
|
||||
-- add to the total if the stamp is still inside the window, otherwise start
|
||||
-- again from this amount.
|
||||
--
|
||||
-- Counters live apart from `team` on purpose. This row is written every time
|
||||
-- anything ticks, while `team` is read on a great many paths with nothing to do
|
||||
-- with billing, and keeping the hot write off the row everyone reads is worth
|
||||
-- the join.
|
||||
--
|
||||
-- A segment is one stretch of one run at one unchanging rate. Not a row per
|
||||
-- session, because a session's rate does not survive its own lifetime -- a
|
||||
-- second run changes what the account spends per second while the first is
|
||||
-- still going. Not a row per event either, because burn accrues continuously
|
||||
-- against an envelope that is held rather than per thing consumed. So the rate
|
||||
-- is stamped at the moment it applied and never edited, and the number shown is
|
||||
-- the number billed because no later pass could reach a different one.
|
||||
--
|
||||
-- `rate_milli` is the per-second rate times a thousand, so fractional factors
|
||||
-- never make any of this floating point.
|
||||
--
|
||||
-- The partial unique index is load-bearing: two open segments for one session
|
||||
-- would double-count every tick, for as long as both stayed open, silently.
|
||||
--
|
||||
-- Sessions and teams are `restrict` on the segment, because deleting a run or a
|
||||
-- team must not erase what it cost. ref(d-0048)
|
||||
|
||||
CREATE TABLE "burn_counter" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"team_id" char(30) NOT NULL,
|
||||
"five_hour_usage" bigint DEFAULT 0 NOT NULL,
|
||||
"five_hour_at" timestamp with time zone,
|
||||
"seven_day_usage" bigint DEFAULT 0 NOT NULL,
|
||||
"seven_day_at" timestamp with time zone,
|
||||
"thirty_day_usage" bigint DEFAULT 0 NOT NULL,
|
||||
"thirty_day_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "burn_segment" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"team_id" char(30) NOT NULL,
|
||||
"session_id" char(30) NOT NULL,
|
||||
"rate_milli" integer NOT NULL,
|
||||
"started_at" timestamp with time zone NOT NULL,
|
||||
"ended_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "burn_counter" ADD CONSTRAINT "burn_counter_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "burn_segment" ADD CONSTRAINT "burn_segment_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "burn_segment" ADD CONSTRAINT "burn_segment_session_id_session_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."session"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "burn_counter_team_unique" ON "burn_counter" USING btree ("team_id");--> statement-breakpoint
|
||||
CREATE INDEX "burn_segment_team_idx" ON "burn_segment" USING btree ("team_id");--> statement-breakpoint
|
||||
CREATE INDEX "burn_segment_session_idx" ON "burn_segment" USING btree ("session_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "burn_segment_one_open_per_session" ON "burn_segment" USING btree ("session_id") WHERE "burn_segment"."ended_at" is null;
|
||||
3234
packages/core/migrations/meta/0016_snapshot.json
Normal file
3234
packages/core/migrations/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,13 @@
|
||||
"when": 1789762221718,
|
||||
"tag": "0015_organisation_owns_fleet_hardware",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1789765075037,
|
||||
"tag": "0016_burn_counters_and_rate_segments",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
99
packages/core/src/billing/burn.sql.ts
Normal file
99
packages/core/src/billing/burn.sql.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { bigint, index, integer, pgTable, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, ulid, utc } from '../db/types.js';
|
||||
import { SessionTable } from '../session/session.sql.js';
|
||||
import { TeamTable } from '../team/team.sql.js';
|
||||
|
||||
/**
|
||||
* What a team has spent, per window, and when that total started.
|
||||
*
|
||||
* Its own table rather than columns on `team`, because this row is written
|
||||
* every time anything ticks while `team` is read on a great many paths that
|
||||
* have nothing to do with billing. Keeping the hot write off the row everyone
|
||||
* reads is the whole reason for the join.
|
||||
*
|
||||
* **Each total is stored beside the time it began**, and a total whose stamp
|
||||
* has fallen outside its window reads as zero. That is what replaces a reset
|
||||
* job: nothing has to run for a window to roll clear, so there is no schedule
|
||||
* to misfire and no race between a reset and a write arriving together. The
|
||||
* same rule on the way in — add to the total if the stamp is still inside the
|
||||
* window, otherwise start again from this amount — is one statement.
|
||||
*
|
||||
* Totals are derived and disposable. {@link BurnSegmentTable} is the record;
|
||||
* these can be rebuilt from it, which is why zeroing one by hand is a support
|
||||
* action rather than data loss.
|
||||
*/
|
||||
export const BurnCounterTable = pgTable(
|
||||
'burn_counter',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
teamId: ulid('team_id')
|
||||
.notNull()
|
||||
.references(() => TeamTable.id, { onDelete: 'cascade' }),
|
||||
// Reference-seconds, so these are readable as time. `bigint` because a
|
||||
// busy team on a long window is well past what an int holds.
|
||||
fiveHourUsage: bigint('five_hour_usage', { mode: 'number' }).notNull().default(0),
|
||||
fiveHourAt: utc('five_hour_at'),
|
||||
sevenDayUsage: bigint('seven_day_usage', { mode: 'number' }).notNull().default(0),
|
||||
sevenDayAt: utc('seven_day_at'),
|
||||
thirtyDayUsage: bigint('thirty_day_usage', { mode: 'number' }).notNull().default(0),
|
||||
thirtyDayAt: utc('thirty_day_at')
|
||||
},
|
||||
(t) => [uniqueIndex('burn_counter_team_unique').on(t.teamId)]
|
||||
);
|
||||
|
||||
/**
|
||||
* One stretch of one run at one unchanging rate.
|
||||
*
|
||||
* Not a row per session and not a row per event. A session's rate is fixed
|
||||
* when it starts — every factor is knowable before the run, which is what lets
|
||||
* a person be told the cost before they commit to it — but it does not stay
|
||||
* fixed for the session's life, because starting a second run changes what the
|
||||
* account spends per second while the first is still going.
|
||||
*
|
||||
* So the record is a segment: opened when the rate becomes true, closed when it
|
||||
* stops being true, and never edited afterwards. Burn is the sum of duration
|
||||
* times rate over segments, every rate stamped at the moment it applied, and
|
||||
* "the number shown is the number billed" holds because there is no later
|
||||
* recalculation that could reach a different answer.
|
||||
*/
|
||||
export const BurnSegmentTable = pgTable(
|
||||
'burn_segment',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
// The billing subject. Denormalized from the session's box on purpose:
|
||||
// which team paid is a fact about the moment, and re-deriving it later
|
||||
// through hardware that may since have moved would answer differently.
|
||||
teamId: ulid('team_id')
|
||||
.notNull()
|
||||
.references(() => TeamTable.id, { onDelete: 'restrict' }),
|
||||
// `restrict`, because deleting a run must not erase what it cost.
|
||||
sessionId: ulid('session_id')
|
||||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: 'restrict' }),
|
||||
/**
|
||||
* Units of burn per second, times a thousand.
|
||||
*
|
||||
* Scaled so the factors can be fractional without any of this becoming
|
||||
* floating point: a rate of 1.5x is 1500. Burn is then
|
||||
* `seconds * rate_milli / 1000`, in integers, and two readers of the
|
||||
* same row cannot disagree in the last digit.
|
||||
*/
|
||||
rateMilli: integer('rate_milli').notNull(),
|
||||
startedAt: utc('started_at').notNull(),
|
||||
/** Null while the segment is the current one for that run. */
|
||||
endedAt: utc('ended_at')
|
||||
},
|
||||
(t) => [
|
||||
index('burn_segment_team_idx').on(t.teamId),
|
||||
index('burn_segment_session_idx').on(t.sessionId),
|
||||
// At most one open segment per run: a second would double-count every
|
||||
// tick for as long as both stayed open, and silently.
|
||||
uniqueIndex('burn_segment_one_open_per_session')
|
||||
.on(t.sessionId)
|
||||
.where(sql`${t.endedAt} is null`)
|
||||
]
|
||||
);
|
||||
227
packages/core/src/billing/burn.test.ts
Normal file
227
packages/core/src/billing/burn.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { Box } from '../box/index.js';
|
||||
import { Fixtures } from '../db/fixtures.js';
|
||||
import { testDb } from '../db/test.js';
|
||||
import { Game } from '../game/index.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { Machine } from '../machine/index.js';
|
||||
import { Session } from '../session/index.js';
|
||||
import { Burn } from './burn.js';
|
||||
import { Window } from './window.js';
|
||||
|
||||
const sql = testDb();
|
||||
|
||||
const createdUserIds: string[] = [];
|
||||
const createdGameIds: string[] = [];
|
||||
|
||||
async function scene(label: string, steamAppId: number) {
|
||||
const owner = await Fixtures.owner(label);
|
||||
createdUserIds.push(owner.userId);
|
||||
const machine = await Machine.register({
|
||||
id: Identifier.ascending('machine'),
|
||||
ownerUserId: owner.userId,
|
||||
teamId: owner.teamId,
|
||||
label
|
||||
});
|
||||
const gameId = Identifier.ascending('game');
|
||||
await Game.upsert({ id: gameId, steamAppId, slug: `${label}-${steamAppId}`, name: label });
|
||||
createdGameIds.push(gameId);
|
||||
|
||||
async function newRun() {
|
||||
const box = await Box.create({
|
||||
id: Identifier.ascending('box'),
|
||||
userId: owner.userId,
|
||||
machineId: machine.id,
|
||||
label,
|
||||
tier: 'sm'
|
||||
});
|
||||
return Session.request({
|
||||
id: Identifier.ascending('session'),
|
||||
boxId: box.id,
|
||||
gameId,
|
||||
linkedAccountId: owner.linkedAccountId
|
||||
});
|
||||
}
|
||||
|
||||
return { owner, teamId: owner.teamId, newRun };
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdUserIds.length > 0) {
|
||||
await sql`delete from "burn_segment" where session_id in (
|
||||
select s.id from "session" s
|
||||
join "box" b on b.id = s.box_id
|
||||
where b.user_id in ${sql(createdUserIds)}
|
||||
)`;
|
||||
await sql`delete from "box" where user_id in ${sql(createdUserIds)}`;
|
||||
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
|
||||
createdUserIds.length = 0;
|
||||
}
|
||||
if (createdGameIds.length > 0) {
|
||||
await sql`delete from "game" where id in ${sql(createdGameIds)}`;
|
||||
createdGameIds.length = 0;
|
||||
}
|
||||
});
|
||||
|
||||
const MINUTE = 60;
|
||||
|
||||
function at(secondsFromStart: number, base: Date) {
|
||||
return new Date(base.getTime() + secondsFromStart * 1000);
|
||||
}
|
||||
|
||||
describe('Segments', () => {
|
||||
test('a run accrues one unit a second while it is the only one', async () => {
|
||||
const s = await scene('burn-solo', 7100);
|
||||
const run = await s.newRun();
|
||||
const t0 = new Date();
|
||||
|
||||
await Burn.start({ teamId: s.teamId, sessionId: run.id, at: t0 });
|
||||
const banked = await Burn.stop({
|
||||
teamId: s.teamId,
|
||||
sessionId: run.id,
|
||||
at: at(10 * MINUTE, t0)
|
||||
});
|
||||
|
||||
expect(banked).toBe(10 * MINUTE);
|
||||
const counters = await Burn.counters(s.teamId);
|
||||
expect(Number(counters?.fiveHourUsage)).toBe(10 * MINUTE);
|
||||
expect(Number(counters?.sevenDayUsage)).toBe(10 * MINUTE);
|
||||
expect(Number(counters?.thirtyDayUsage)).toBe(10 * MINUTE);
|
||||
});
|
||||
|
||||
test('two at once cost two a second between them, not four', async () => {
|
||||
// The factor is on the total. Two deadline guarantees cost twice one,
|
||||
// and reading it per-session would have made this four.
|
||||
const s = await scene('burn-pair', 7101);
|
||||
const first = await s.newRun();
|
||||
const second = await s.newRun();
|
||||
const t0 = new Date();
|
||||
|
||||
await Burn.start({ teamId: s.teamId, sessionId: first.id, at: t0 });
|
||||
await Burn.start({ teamId: s.teamId, sessionId: second.id, at: t0 });
|
||||
await Burn.stop({ teamId: s.teamId, sessionId: first.id, at: at(MINUTE, t0) });
|
||||
await Burn.stop({ teamId: s.teamId, sessionId: second.id, at: at(MINUTE, t0) });
|
||||
|
||||
const counters = await Burn.counters(s.teamId);
|
||||
expect(Number(counters?.fiveHourUsage)).toBe(2 * MINUTE);
|
||||
});
|
||||
|
||||
test('a run only pays the higher rate for the time the sibling was there', async () => {
|
||||
// The reason segments exist. A rate that applied from the moment the
|
||||
// second run started must not be backdated over the first run's solo
|
||||
// time, and must not linger after the sibling has gone.
|
||||
const s = await scene('burn-overlap', 7102);
|
||||
const long = await s.newRun();
|
||||
const brief = await s.newRun();
|
||||
const t0 = new Date();
|
||||
|
||||
await Burn.start({ teamId: s.teamId, sessionId: long.id, at: t0 });
|
||||
// One minute alone.
|
||||
await Burn.start({ teamId: s.teamId, sessionId: brief.id, at: at(MINUTE, t0) });
|
||||
// One minute together, which costs two.
|
||||
await Burn.stop({ teamId: s.teamId, sessionId: brief.id, at: at(2 * MINUTE, t0) });
|
||||
// One minute alone again.
|
||||
await Burn.stop({ teamId: s.teamId, sessionId: long.id, at: at(3 * MINUTE, t0) });
|
||||
|
||||
// 60 solo + 120 shared + 60 solo.
|
||||
const counters = await Burn.counters(s.teamId);
|
||||
expect(Number(counters?.fiveHourUsage)).toBe(4 * MINUTE);
|
||||
});
|
||||
|
||||
test('stopping twice does not bill twice', async () => {
|
||||
const s = await scene('burn-idempotent', 7103);
|
||||
const run = await s.newRun();
|
||||
const t0 = new Date();
|
||||
|
||||
await Burn.start({ teamId: s.teamId, sessionId: run.id, at: t0 });
|
||||
await Burn.stop({ teamId: s.teamId, sessionId: run.id, at: at(MINUTE, t0) });
|
||||
const second = await Burn.stop({
|
||||
teamId: s.teamId,
|
||||
sessionId: run.id,
|
||||
at: at(2 * MINUTE, t0)
|
||||
});
|
||||
|
||||
expect(second).toBe(0);
|
||||
expect(Number((await Burn.counters(s.teamId))?.fiveHourUsage)).toBe(MINUTE);
|
||||
});
|
||||
|
||||
test('resegmenting mid-run banks what has been spent without stopping it', async () => {
|
||||
// A long run has to land incrementally: burn that only arrives when a
|
||||
// session ends is burn that cannot refuse the next one.
|
||||
const s = await scene('burn-tick', 7104);
|
||||
const run = await s.newRun();
|
||||
const t0 = new Date();
|
||||
|
||||
await Burn.start({ teamId: s.teamId, sessionId: run.id, at: t0 });
|
||||
await Burn.resegment({ teamId: s.teamId, at: at(5 * MINUTE, t0) });
|
||||
|
||||
expect(Number((await Burn.counters(s.teamId))?.fiveHourUsage)).toBe(5 * MINUTE);
|
||||
expect((await Burn.openSegments(s.teamId)).length).toBe(1);
|
||||
|
||||
await Burn.stop({ teamId: s.teamId, sessionId: run.id, at: at(6 * MINUTE, t0) });
|
||||
expect(Number((await Burn.counters(s.teamId))?.fiveHourUsage)).toBe(6 * MINUTE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('The counters', () => {
|
||||
test('the first tick and the thousandth are the same call', async () => {
|
||||
// There is no row until something burns, and a read-then-insert would
|
||||
// race two first ticks into two rows the unique index then refuses —
|
||||
// turning an ordinary heartbeat into an error.
|
||||
const s = await scene('burn-first', 7105);
|
||||
expect(await Burn.counters(s.teamId)).toBeNull();
|
||||
|
||||
await Burn.record({ teamId: s.teamId, amount: 30 });
|
||||
await Burn.record({ teamId: s.teamId, amount: 12 });
|
||||
expect(Number((await Burn.counters(s.teamId))?.fiveHourUsage)).toBe(42);
|
||||
});
|
||||
|
||||
test('a total whose window has rolled past starts again rather than adding', async () => {
|
||||
// The staleness rule on the write side. Asserted through the stored
|
||||
// stamp, because this is the behaviour that replaces a reset job.
|
||||
const s = await scene('burn-stale', 7106);
|
||||
await Burn.record({ teamId: s.teamId, amount: 100 });
|
||||
|
||||
// Age the five-hour stamp past its window, leaving the others fresh.
|
||||
await sql`
|
||||
update "burn_counter"
|
||||
set five_hour_at = now() - make_interval(secs => ${Window.FIVE_HOURS + 60})
|
||||
where team_id = ${s.teamId}
|
||||
`;
|
||||
await Burn.record({ teamId: s.teamId, amount: 7 });
|
||||
|
||||
const counters = await Burn.counters(s.teamId);
|
||||
// Started again from the new amount alone.
|
||||
expect(Number(counters?.fiveHourUsage)).toBe(7);
|
||||
// The windows that had not rolled kept accumulating.
|
||||
expect(Number(counters?.sevenDayUsage)).toBe(107);
|
||||
expect(Number(counters?.thirtyDayUsage)).toBe(107);
|
||||
});
|
||||
|
||||
test('recording nothing writes nothing', async () => {
|
||||
const s = await scene('burn-zero', 7107);
|
||||
await Burn.record({ teamId: s.teamId, amount: 0 });
|
||||
expect(await Burn.counters(s.teamId)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rates', () => {
|
||||
test('a run costs the same whoever is running it', () => {
|
||||
// The plan buys an allowance, never a discount on the meter. If the
|
||||
// rate moved with the tier, an upgrade would change what past runs cost
|
||||
// and the bars would stop being comparable.
|
||||
expect(Burn.rateMilliFor(1)).toBe(Burn.SCALE);
|
||||
expect(Burn.rateMilliFor(4)).toBe(Burn.SCALE);
|
||||
});
|
||||
|
||||
test('nothing running costs nothing', () => {
|
||||
expect(Burn.rateMilliFor(0)).toBe(0);
|
||||
});
|
||||
|
||||
test('burn is whole seconds, never a fraction of one', () => {
|
||||
expect(Burn.amountFor(90, 1500)).toBe(135);
|
||||
expect(Burn.amountFor(1, 1)).toBe(0);
|
||||
expect(Burn.amountFor(-5, Burn.SCALE)).toBe(0);
|
||||
});
|
||||
});
|
||||
280
packages/core/src/billing/burn.ts
Normal file
280
packages/core/src/billing/burn.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { and, eq, isNull, sql, type SQL } from 'drizzle-orm';
|
||||
import type { PgColumn } from 'drizzle-orm/pg-core';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { BurnCounterTable, BurnSegmentTable } from './burn.sql.js';
|
||||
import { Window } from './window.js';
|
||||
|
||||
/**
|
||||
* Recording what a team spends, as segments at a constant rate.
|
||||
*
|
||||
* The rate of a run is knowable before it starts — that is what lets somebody
|
||||
* be told the cost before they commit — but it does not stay fixed, because a
|
||||
* second run changes what the account spends per second while the first is
|
||||
* still going. So burn is recorded as stretches at one rate: opened when the
|
||||
* rate becomes true, closed when it stops being, never edited after.
|
||||
*
|
||||
* Closing a segment is what moves burn into the counters, so a long run lands
|
||||
* incrementally rather than all at the end. That matters for more than
|
||||
* freshness: burn that only arrives when a session stops is burn that cannot
|
||||
* stop the *next* session from starting, and a bar that does not move while
|
||||
* something is running is a bar nobody believes.
|
||||
*/
|
||||
export namespace Burn {
|
||||
/** Rates are scaled by this so fractional factors stay integers. */
|
||||
export const SCALE = 1000;
|
||||
|
||||
/**
|
||||
* What one running session costs per second, given how many are running.
|
||||
*
|
||||
* On hardware the caller owns this is the whole calculation: the factors
|
||||
* that price a share of a card price *our* cost basis, and on somebody
|
||||
* else's card there is no card of ours being spent, so they are 1 and burn
|
||||
* is duration times how much is running at once.
|
||||
*
|
||||
* The factor is on the **total**, not on each session — N concurrent runs
|
||||
* cost N between them, and a run's own rate does not change because a
|
||||
* sibling started. Two deadline guarantees cost twice one, not four times.
|
||||
* Anything steeper would be a commercial decision to price concentration
|
||||
* above cost, and has not been made.
|
||||
*/
|
||||
export function rateMilliFor(concurrency: number): number {
|
||||
if (concurrency <= 0) {
|
||||
return 0;
|
||||
}
|
||||
// Total rate is `concurrency`, shared equally, so each open segment
|
||||
// carries 1x. Spelled out rather than written as the constant it
|
||||
// currently equals, because this is the line that changes if the factor
|
||||
// ever moves off cost.
|
||||
return SCALE;
|
||||
}
|
||||
|
||||
/** Burn from one closed stretch, in whole reference-seconds. */
|
||||
export function amountFor(seconds: number, rateMilli: number): number {
|
||||
return Math.floor((Math.max(0, seconds) * rateMilli) / SCALE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to a window's total, or start it again, in one statement.
|
||||
*
|
||||
* The `CASE` is the whole staleness rule on the write side: if the stamp is
|
||||
* still inside the window the amount joins the total and the stamp is left
|
||||
* where it was; if it has rolled out, the total *becomes* this amount and
|
||||
* the stamp moves to now. Reading and then deciding would be two statements
|
||||
* with a gap in between, and the gap is where a concurrent tick doubles or
|
||||
* vanishes.
|
||||
*/
|
||||
function windowSet(
|
||||
usageColumn: PgColumn,
|
||||
atColumn: PgColumn,
|
||||
windowSeconds: number,
|
||||
amount: number
|
||||
): { usage: SQL; at: SQL } {
|
||||
const fresh = sql`${atColumn} >= now() - make_interval(secs => ${windowSeconds})`;
|
||||
return {
|
||||
usage: sql`case when ${fresh} then ${usageColumn} + ${amount} else ${amount} end`,
|
||||
at: sql`case when ${fresh} then ${atColumn} else now() end`
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply one amount of burn to all three of a team's windows. */
|
||||
export const record = fn(
|
||||
z.object({ teamId: z.string(), amount: z.number().int().nonnegative() }),
|
||||
async (input) => {
|
||||
if (input.amount === 0) {
|
||||
return;
|
||||
}
|
||||
const five = windowSet(
|
||||
BurnCounterTable.fiveHourUsage,
|
||||
BurnCounterTable.fiveHourAt,
|
||||
Window.FIVE_HOURS,
|
||||
input.amount
|
||||
);
|
||||
const seven = windowSet(
|
||||
BurnCounterTable.sevenDayUsage,
|
||||
BurnCounterTable.sevenDayAt,
|
||||
Window.SEVEN_DAYS,
|
||||
input.amount
|
||||
);
|
||||
const thirty = windowSet(
|
||||
BurnCounterTable.thirtyDayUsage,
|
||||
BurnCounterTable.thirtyDayAt,
|
||||
Window.THIRTY_DAYS,
|
||||
input.amount
|
||||
);
|
||||
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.insert(BurnCounterTable)
|
||||
.values({
|
||||
id: Identifier.ascending('burnCounter'),
|
||||
teamId: input.teamId,
|
||||
fiveHourUsage: input.amount,
|
||||
fiveHourAt: sql`now()`,
|
||||
sevenDayUsage: input.amount,
|
||||
sevenDayAt: sql`now()`,
|
||||
thirtyDayUsage: input.amount,
|
||||
thirtyDayAt: sql`now()`
|
||||
})
|
||||
// The first tick for a team and the thousandth are the same
|
||||
// call. A read-then-insert would race two first ticks into two
|
||||
// rows, which the unique index would then refuse — turning an
|
||||
// ordinary heartbeat into an error.
|
||||
.onConflictDoUpdate({
|
||||
target: BurnCounterTable.teamId,
|
||||
set: {
|
||||
fiveHourUsage: five.usage,
|
||||
fiveHourAt: five.at,
|
||||
sevenDayUsage: seven.usage,
|
||||
sevenDayAt: seven.at,
|
||||
thirtyDayUsage: thirty.usage,
|
||||
thirtyDayAt: thirty.at
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/** A team's three totals, or nulls where nothing has been recorded. */
|
||||
export const counters = fn(z.string(), async (teamId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(BurnCounterTable)
|
||||
.where(eq(BurnCounterTable.teamId, teamId))
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
/** Every run currently accruing for a team. */
|
||||
export const openSegments = fn(z.string(), async (teamId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(BurnSegmentTable)
|
||||
.where(and(eq(BurnSegmentTable.teamId, teamId), isNull(BurnSegmentTable.endedAt)))
|
||||
.orderBy(BurnSegmentTable.startedAt);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Close every open stretch for a team and start new ones at the new rate.
|
||||
*
|
||||
* Called whenever the number of running sessions changes, and periodically
|
||||
* while they run so the counters do not lag a long session. It is one
|
||||
* operation rather than a close and an open, because between them the
|
||||
* account would be spending nothing — and a tick that lands in that gap
|
||||
* would record a rate nobody was ever charged.
|
||||
*
|
||||
* Safe to call when nothing has changed: a segment closed and reopened at
|
||||
* the same rate bills identically, it is just two rows instead of one.
|
||||
*/
|
||||
export const resegment = fn(
|
||||
z.object({ teamId: z.string(), at: z.date().optional() }),
|
||||
async (input) => {
|
||||
return Database.transaction(async (tx) => {
|
||||
const now = input.at ?? new Date();
|
||||
const open = await tx
|
||||
.select()
|
||||
.from(BurnSegmentTable)
|
||||
.where(and(eq(BurnSegmentTable.teamId, input.teamId), isNull(BurnSegmentTable.endedAt)));
|
||||
|
||||
if (open.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (const segment of open) {
|
||||
const seconds = Math.floor((now.getTime() - segment.startedAt.getTime()) / 1000);
|
||||
total += amountFor(seconds, segment.rateMilli);
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(BurnSegmentTable)
|
||||
.set({ endedAt: now })
|
||||
.where(and(eq(BurnSegmentTable.teamId, input.teamId), isNull(BurnSegmentTable.endedAt)));
|
||||
|
||||
const rateMilli = rateMilliFor(open.length);
|
||||
if (open.length > 0) {
|
||||
await tx.insert(BurnSegmentTable).values(
|
||||
open.map((segment) => ({
|
||||
id: Identifier.ascending('burnSegment'),
|
||||
teamId: segment.teamId,
|
||||
sessionId: segment.sessionId,
|
||||
rateMilli,
|
||||
startedAt: now
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
await record({ teamId: input.teamId, amount: total });
|
||||
return total;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Start accruing for a run.
|
||||
*
|
||||
* Existing runs are resegmented first, so their old rate is banked before
|
||||
* the new count applies to anybody — otherwise the change would be
|
||||
* backdated over time that was spent under the old one.
|
||||
*/
|
||||
export const start = fn(
|
||||
z.object({ teamId: z.string(), sessionId: z.string(), at: z.date().optional() }),
|
||||
async (input) => {
|
||||
return Database.transaction(async (tx) => {
|
||||
const now = input.at ?? new Date();
|
||||
await resegment({ teamId: input.teamId, at: now });
|
||||
|
||||
const open = await tx
|
||||
.select()
|
||||
.from(BurnSegmentTable)
|
||||
.where(and(eq(BurnSegmentTable.teamId, input.teamId), isNull(BurnSegmentTable.endedAt)));
|
||||
|
||||
await tx.insert(BurnSegmentTable).values({
|
||||
id: Identifier.ascending('burnSegment'),
|
||||
teamId: input.teamId,
|
||||
sessionId: input.sessionId,
|
||||
rateMilli: rateMilliFor(open.length + 1),
|
||||
startedAt: now
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Stop accruing for a run, banking what it spent.
|
||||
*
|
||||
* The remaining runs are resegmented afterwards, so they stop paying for a
|
||||
* sibling that has gone. Idempotent: a run with nothing open is a run that
|
||||
* already stopped, and saying so twice must not bill twice.
|
||||
*/
|
||||
export const stop = fn(
|
||||
z.object({ teamId: z.string(), sessionId: z.string(), at: z.date().optional() }),
|
||||
async (input) => {
|
||||
return Database.transaction(async (tx) => {
|
||||
const now = input.at ?? new Date();
|
||||
const closed = await tx
|
||||
.update(BurnSegmentTable)
|
||||
.set({ endedAt: now })
|
||||
.where(
|
||||
and(eq(BurnSegmentTable.sessionId, input.sessionId), isNull(BurnSegmentTable.endedAt))
|
||||
)
|
||||
.returning();
|
||||
|
||||
let total = 0;
|
||||
for (const segment of closed) {
|
||||
const seconds = Math.floor((now.getTime() - segment.startedAt.getTime()) / 1000);
|
||||
total += amountFor(seconds, segment.rateMilli);
|
||||
}
|
||||
await record({ teamId: input.teamId, amount: total });
|
||||
await resegment({ teamId: input.teamId, at: now });
|
||||
return total;
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
123
packages/core/src/billing/index.ts
Normal file
123
packages/core/src/billing/index.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import z from 'zod';
|
||||
|
||||
import { Box } from '../box/index.js';
|
||||
import { ErrorCodes, VisibleError } from '../error.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { Machine } from '../machine/index.js';
|
||||
import { Team } from '../team/index.js';
|
||||
import { Burn } from './burn.js';
|
||||
import { Limits } from './limits.js';
|
||||
import { Window } from './window.js';
|
||||
|
||||
/**
|
||||
* Whether a run may start, and what it will cost to have started it.
|
||||
*
|
||||
* The one rule this must never break: **a limit refuses the next run, it never
|
||||
* stops one already going.** Someone losing a session mid-game to a meter does
|
||||
* not come back, and no amount of correct arithmetic makes that a good trade.
|
||||
* So everything here is asked before a run begins and never again.
|
||||
*/
|
||||
export namespace Billing {
|
||||
export const WindowState = Window.State.extend({
|
||||
window: z.enum(['fiveHour', 'sevenDay', 'thirtyDay']),
|
||||
label: z.string()
|
||||
});
|
||||
|
||||
export const State = z.object({
|
||||
teamId: z.string(),
|
||||
plan: z.string(),
|
||||
/** True when any window is spent. */
|
||||
exhausted: z.boolean(),
|
||||
/**
|
||||
* What the account is spending per second right now, times a thousand,
|
||||
* and what it would spend with one more run.
|
||||
*
|
||||
* Both, because the rule is that a cost is shown *before* it is
|
||||
* incurred: a person about to start a third session needs to be told
|
||||
* what that does to the rate while they can still not do it.
|
||||
*/
|
||||
rateMilli: z.number().int(),
|
||||
rateMilliIfOneMore: z.number().int(),
|
||||
windows: z.array(WindowState)
|
||||
});
|
||||
|
||||
export type State = z.infer<typeof State>;
|
||||
|
||||
/**
|
||||
* The team that pays for a box.
|
||||
*
|
||||
* A box runs on a host, and the host says who owns it. Fleet hardware
|
||||
* belongs to an organisation, which is not a billing subject — nothing is
|
||||
* placed there yet, and when it is, what grants it is a plan rather than
|
||||
* this lookup.
|
||||
*/
|
||||
export const teamForBox = fn(z.string(), async (boxId) => {
|
||||
const box = await Box.fromID(boxId);
|
||||
if (!box) {
|
||||
return null;
|
||||
}
|
||||
const machine = await Machine.fromID(box.machineId);
|
||||
return machine?.teamId ?? null;
|
||||
});
|
||||
|
||||
/** Where a team stands, in every window, with the rates to show beside it. */
|
||||
export const state = fn(z.string(), async (teamId): Promise<State> => {
|
||||
const team = await Team.fromID(teamId);
|
||||
const plan = team?.plan ?? 'free';
|
||||
const allowances = Limits.forPlan(plan);
|
||||
const counters = await Burn.counters(teamId);
|
||||
const open = await Burn.openSegments(teamId);
|
||||
|
||||
const windows = Window.ALL.map((window) => {
|
||||
const usage = counters ? Number(counters[`${window.key}Usage`] ?? 0) : 0;
|
||||
const at = counters ? (counters[`${window.key}At`] ?? null) : null;
|
||||
return {
|
||||
window: window.key,
|
||||
label: window.label,
|
||||
...Window.analyze({
|
||||
allowance: allowances[window.key],
|
||||
windowSeconds: window.seconds,
|
||||
usage,
|
||||
timeUpdated: at
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
teamId,
|
||||
plan,
|
||||
exhausted: windows.some((w) => w.exhausted),
|
||||
// The account's total, which is what moves when a run starts — not
|
||||
// any one session's own rate, which does not change.
|
||||
rateMilli: open.length * Burn.rateMilliFor(open.length),
|
||||
rateMilliIfOneMore: (open.length + 1) * Burn.rateMilliFor(open.length + 1),
|
||||
windows
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Refuse a new run when any window is spent.
|
||||
*
|
||||
* Every window is checked, not the shortest: they protect different things
|
||||
* over different spans, and a set where only one could ever fire is a set
|
||||
* with two decorative numbers in it.
|
||||
*
|
||||
* The refusal names the window and when it clears, because a limit a person
|
||||
* cannot plan around is the one they resent. `QUOTA_EXCEEDED` maps to 429,
|
||||
* which is the honest status — this is a rate limit the customer experiences
|
||||
* as a budget, and it will succeed later without anything changing.
|
||||
*/
|
||||
export const assertMayStart = fn(z.string(), async (teamId) => {
|
||||
const current = await state(teamId);
|
||||
const spent = current.windows.find((w) => w.exhausted);
|
||||
if (!spent) {
|
||||
return current;
|
||||
}
|
||||
const minutes = Math.ceil(spent.resetInSec / 60);
|
||||
throw new VisibleError(
|
||||
'rate_limit',
|
||||
ErrorCodes.RateLimit.QUOTA_EXCEEDED,
|
||||
`Your ${spent.label} allowance is spent. It clears in about ${minutes} minute${minutes === 1 ? '' : 's'}. Runs already going are not affected.`
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export namespace Identifier {
|
||||
gameDepot: 'gdp',
|
||||
gameDownload: 'gdl',
|
||||
waitlistEntry: 'wle',
|
||||
burnCounter: 'bct',
|
||||
burnSegment: 'bsg',
|
||||
deviceGrant: 'dvg',
|
||||
authKv: 'akv',
|
||||
authKey: 'aky',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Burn } from '../billing/burn.js';
|
||||
import { BoxTable, BoxTier } from '../box/box.sql.js';
|
||||
import { Box } from '../box/index.js';
|
||||
import { Database } from '../db/index.js';
|
||||
@@ -8,6 +9,7 @@ import { ErrorCodes, VisibleError } from '../error.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { GameTable } from '../game/game.sql.js';
|
||||
import { Machine } from '../machine/index.js';
|
||||
import { SessionState, SessionTable } from './session.sql.js';
|
||||
|
||||
/**
|
||||
@@ -650,6 +652,27 @@ export namespace Session {
|
||||
await Box.setState({ id: moved.boxId, ...box });
|
||||
}
|
||||
|
||||
// Burn follows the run's state, in the same transaction that
|
||||
// moved it. A session that went live without its meter starting
|
||||
// is free hardware; one that ended without its meter stopping
|
||||
// bills forever. Both are silent, so neither may be a second
|
||||
// write that might not happen.
|
||||
//
|
||||
// `live` is where it starts rather than `starting`, because what
|
||||
// is billed is an envelope actually held — a box that never
|
||||
// finished coming up held nothing.
|
||||
const machine = await Machine.fromID(input.machineId);
|
||||
if (machine?.teamId) {
|
||||
if (current.state !== 'live' && moved.state === 'live') {
|
||||
await Burn.start({ teamId: machine.teamId, sessionId: moved.id });
|
||||
} else if (
|
||||
ACCRUING.includes(current.state as (typeof ACCRUING)[number]) &&
|
||||
!ACCRUING.includes(moved.state as (typeof ACCRUING)[number])
|
||||
) {
|
||||
await Burn.stop({ teamId: machine.teamId, sessionId: moved.id });
|
||||
}
|
||||
}
|
||||
|
||||
return { outcome: 'moved', session: moved };
|
||||
});
|
||||
}
|
||||
@@ -671,6 +694,16 @@ export namespace Session {
|
||||
*/
|
||||
const ADDRESSABLE = ['starting', 'live'] as const;
|
||||
|
||||
/**
|
||||
* The states in which a run is spending.
|
||||
*
|
||||
* Only `live`. A run that is being brought up holds nothing yet, and the
|
||||
* terminal states hold nothing any more — so this is a list of one, written
|
||||
* as a list because the question it answers is "is this run costing
|
||||
* anything?" and that will not always have one answer.
|
||||
*/
|
||||
const ACCRUING = ['live'] as const;
|
||||
|
||||
/**
|
||||
* Publish a ticket for a run, on behalf of the host it is placed on.
|
||||
*
|
||||
|
||||
@@ -47,7 +47,14 @@ async function scene(label: string, steamAppId: number) {
|
||||
afterAll(async () => {
|
||||
if (createdUserIds.length > 0) {
|
||||
// session cascades from box; box has to precede the machine, which
|
||||
// cascades from the user.
|
||||
// cascades from the user. `burn_segment` holds a session with
|
||||
// `restrict` — deleting a run must not erase what it cost — so what the
|
||||
// runs cost goes before the runs do.
|
||||
await sql`delete from "burn_segment" where session_id in (
|
||||
select s.id from "session" s
|
||||
join "box" b on b.id = s.box_id
|
||||
where b.user_id in ${sql(createdUserIds)}
|
||||
)`;
|
||||
await sql`delete from "box" where user_id in ${sql(createdUserIds)}`;
|
||||
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
|
||||
createdUserIds.length = 0;
|
||||
|
||||
Reference in New Issue
Block a user