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:
Wanjohi
2026-09-19 00:19:39 +03:00
parent b819367a09
commit b4776fad2f
13 changed files with 4146 additions and 7 deletions

View File

@@ -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 boxs 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 boxs 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 accounts 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(

View File

@@ -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 }));
}

View File

@@ -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,