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:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user