mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25: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(
|
||||
|
||||
Reference in New Issue
Block a user