diff --git a/apps/api/app/routes/session.ts b/apps/api/app/routes/session.ts index 134c6825..e5c5696f 100644 --- a/apps/api/app/routes/session.ts +++ b/apps/api/app/routes/session.ts @@ -236,8 +236,14 @@ export namespace SessionApi { // 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 payer = await Billing.teamForBox(box.id); + const billing = payer + ? await Billing.assertMayStart({ + teamId: payer.teamId, + nextTier: payer.tier, + nextHostClass: payer.hostClass + }) + : null; const session = await Session.request({ id: Identifier.ascending('session'), diff --git a/packages/core/src/billing/burn.test.ts b/packages/core/src/billing/burn.test.ts index 9398dbb2..5dfb01a7 100644 --- a/packages/core/src/billing/burn.test.ts +++ b/packages/core/src/billing/burn.test.ts @@ -76,7 +76,7 @@ describe('Segments', () => { const run = await s.newRun(); const t0 = new Date(); - await Burn.start({ teamId: s.teamId, sessionId: run.id, at: t0 }); + await Burn.start({ teamId: s.teamId, sessionId: run.id, tier: 'sm', hostClass: 'byo', at: t0 }); const banked = await Burn.stop({ teamId: s.teamId, sessionId: run.id, @@ -98,8 +98,20 @@ describe('Segments', () => { 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.start({ + teamId: s.teamId, + sessionId: first.id, + tier: 'sm', + hostClass: 'byo', + at: t0 + }); + await Burn.start({ + teamId: s.teamId, + sessionId: second.id, + tier: 'sm', + hostClass: 'byo', + 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) }); @@ -116,9 +128,21 @@ describe('Segments', () => { const brief = await s.newRun(); const t0 = new Date(); - await Burn.start({ teamId: s.teamId, sessionId: long.id, at: t0 }); + await Burn.start({ + teamId: s.teamId, + sessionId: long.id, + tier: 'sm', + hostClass: 'byo', + at: t0 + }); // One minute alone. - await Burn.start({ teamId: s.teamId, sessionId: brief.id, at: at(MINUTE, t0) }); + await Burn.start({ + teamId: s.teamId, + sessionId: brief.id, + tier: 'sm', + hostClass: 'byo', + 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. @@ -134,7 +158,7 @@ describe('Segments', () => { const run = await s.newRun(); const t0 = new Date(); - await Burn.start({ teamId: s.teamId, sessionId: run.id, at: t0 }); + await Burn.start({ teamId: s.teamId, sessionId: run.id, tier: 'sm', hostClass: 'byo', at: t0 }); await Burn.stop({ teamId: s.teamId, sessionId: run.id, at: at(MINUTE, t0) }); const second = await Burn.stop({ teamId: s.teamId, @@ -153,7 +177,7 @@ describe('Segments', () => { const run = await s.newRun(); const t0 = new Date(); - await Burn.start({ teamId: s.teamId, sessionId: run.id, at: t0 }); + await Burn.start({ teamId: s.teamId, sessionId: run.id, tier: 'sm', hostClass: 'byo', at: t0 }); await Burn.resegment({ teamId: s.teamId, at: at(5 * MINUTE, t0) }); expect(Number((await Burn.counters(s.teamId))?.fiveHourUsage)).toBe(5 * MINUTE); @@ -207,16 +231,31 @@ describe('The counters', () => { }); 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('on our hardware a bigger tier costs more, superlinearly', () => { + const rate = (tier: Burn.Tier) => Burn.baseRateMilli({ tier, hostClass: 'fleet' }); + expect(rate('sm')).toBe(Burn.SCALE); + expect(rate('xl')).toBeGreaterThan(rate('lg')); + // A tier buys a share of a card, and the ladder has to pinch harder + // than the share grows or running a small title at the top of it is + // cheaper than it costs us. + expect(rate('xl') / rate('sm')).toBeGreaterThan(4); }); - test('nothing running costs nothing', () => { - expect(Burn.rateMilliFor(0)).toBe(0); + test('on the caller\u2019s own hardware the tier changes nothing', () => { + // There is no share of a card of ours being spent, so charging more for + // a bigger one would be a tax on hardware they bought. + for (const tier of ['xs', 'sm', 'md', 'lg', 'xl'] as const) { + expect(Burn.baseRateMilli({ tier, hostClass: 'byo' })).toBe(Burn.SCALE); + } + }); + + test('a run costs the same whatever plan is paying for it', () => { + // The plan buys an allowance, never a discount on the meter. If the + // rate moved with the tier somebody is on, an upgrade would change what + // past runs cost and the bars would stop being comparable. + expect(Burn.baseRateMilli({ tier: 'md', hostClass: 'fleet' })).toBe( + Burn.baseRateMilli({ tier: 'md', hostClass: 'fleet' }) + ); }); test('burn is whole seconds, never a fraction of one', () => { diff --git a/packages/core/src/billing/burn.ts b/packages/core/src/billing/burn.ts index ca7bc166..ce60da97 100644 --- a/packages/core/src/billing/burn.ts +++ b/packages/core/src/billing/burn.ts @@ -6,6 +6,7 @@ import { Database } from '../db/index.js'; import { fn } from '../fn.js'; import { Identifier } from '../id.js'; import { BurnCounterTable, BurnSegmentTable } from './burn.sql.js'; +import { Limits } from './limits.js'; import { Window } from './window.js'; /** @@ -27,30 +28,39 @@ export namespace Burn { /** Rates are scaled by this so fractional factors stay integers. */ export const SCALE = 1000; + /** Whose hardware a run is on, which is what decides the cost basis. */ + export const HostClass = z.enum(['byo', 'fleet']); + export type HostClass = z.infer; + + export const Tier = z.enum(['xs', 'sm', 'md', 'lg', 'xl']); + export type Tier = z.infer; + /** - * What one running session costs per second, given how many are running. + * What one run costs per second, before anything else is 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. + * **On our own hardware the tier decides it**, because a tier buys a share + * of a card we paid for and a bigger share is more of something real being + * spent. On the caller's own hardware it does not: there is no share of a + * card of ours in play, so a run costs one unit a second whatever size it + * asked for. Charging somebody more for taking more of their own GPU is a + * tax on hardware they bought, and avoiding that is most of the point. * - * 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. + * Note what is *not* here: the number of other runs. Concurrency is on the + * account's total, not on any one run — two deadline guarantees cost twice + * one, so two runs cost the sum of their two rates and neither of them gets + * more expensive because the other started. That is why this rate is fixed + * for a run's whole life, and why a sibling starting does not have to + * rewrite anything. */ - export function rateMilliFor(concurrency: number): number { - if (concurrency <= 0) { - return 0; + export const baseRateMilli = fn( + z.object({ tier: Tier, hostClass: HostClass }), + (input): number => { + if (input.hostClass === 'byo') { + return SCALE; + } + return Limits.get().factors.size[input.tier]; } - // 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 { @@ -197,18 +207,20 @@ export namespace Burn { .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 - })) - ); - } + // Each run keeps its own rate. It is a property of what that run + // is — its tier, and whose hardware it sits on — and none of that + // changed because the clock ticked or a sibling appeared. + // Recomputing a single shared rate here would quietly reprice an + // `xl` run as whatever the last one to start was. + await tx.insert(BurnSegmentTable).values( + open.map((segment) => ({ + id: Identifier.ascending('burnSegment'), + teamId: segment.teamId, + sessionId: segment.sessionId, + rateMilli: segment.rateMilli, + startedAt: now + })) + ); await record({ teamId: input.teamId, amount: total }); return total; @@ -224,22 +236,26 @@ export namespace Burn { * 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() }), + z.object({ + teamId: z.string(), + sessionId: z.string(), + tier: Tier, + hostClass: HostClass, + at: z.date().optional() + }), async (input) => { return Database.transaction(async (tx) => { const now = input.at ?? new Date(); + // Bank what the runs already going have spent, so the moment this + // one appears is a clean boundary in the record rather than a + // point inside somebody else's open stretch. 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), + rateMilli: baseRateMilli({ tier: input.tier, hostClass: input.hostClass }), startedAt: now }); }); diff --git a/packages/core/src/billing/index.ts b/packages/core/src/billing/index.ts index 78ed5f78..ea4fda70 100644 --- a/packages/core/src/billing/index.ts +++ b/packages/core/src/billing/index.ts @@ -57,43 +57,70 @@ export namespace Billing { return null; } const machine = await Machine.fromID(box.machineId); - return machine?.teamId ?? null; + if (!machine?.teamId) { + return null; + } + return { + teamId: machine.teamId, + tier: box.tier as Burn.Tier, + // Whose hardware decides the cost basis, and the machine is the only + // thing that knows. A host an organisation owns is ours to pay for; + // anything else is the caller's own card. + hostClass: (machine.organisationId ? 'fleet' : 'byo') as Burn.HostClass + }; }); /** Where a team stands, in every window, with the rates to show beside it. */ - export const state = fn(z.string(), async (teamId): Promise => { - 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); + export const state = fn( + z.object({ + teamId: z.string(), + /** The run being considered, so "one more" can be costed honestly. */ + nextTier: Burn.Tier.optional(), + nextHostClass: Burn.HostClass.optional() + }), + async (input): Promise => { + const teamId = input.teamId; + 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 + }) + }; + }); + + // The account's total is the sum of what each run costs, not a count + // times one rate: an `xl` run and an `xs` one alongside it are not + // two of anything. Concurrency shows up here, as there being more to + // add, rather than as a multiplier on any of them. + const rateMilli = open.reduce((total, segment) => total + segment.rateMilli, 0); + const next = Burn.baseRateMilli({ + tier: input.nextTier ?? 'sm', + hostClass: input.nextHostClass ?? 'byo' + }); - 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 - }) + teamId, + plan, + exhausted: windows.some((w) => w.exhausted), + rateMilli, + rateMilliIfOneMore: rateMilli + next, + windows }; - }); - - 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. @@ -107,17 +134,24 @@ export namespace Billing { * 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; + export const assertMayStart = fn( + z.object({ + teamId: z.string(), + nextTier: Burn.Tier.optional(), + nextHostClass: Burn.HostClass.optional() + }), + async (input) => { + const current = await state(input); + 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.` + ); } - 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.` - ); - }); + ); } diff --git a/packages/core/src/billing/limits.test.ts b/packages/core/src/billing/limits.test.ts index 8d981804..841cacb3 100644 --- a/packages/core/src/billing/limits.test.ts +++ b/packages/core/src/billing/limits.test.ts @@ -67,6 +67,16 @@ describe('The nesting rule: every window has to bind', () => { expect(() => Limits.check(hours(10, 335, 1000), 'free')).not.toThrow(); }); + test('the reference tier has to cost exactly one unit a second', () => { + // The unit *is* a second of a reference session, so moving `sm` off 1 + // would silently redefine every allowance — the same stored number + // would be a different number of hours. + expect(() => + Limits.checkFactors({ size: { xs: 500, sm: 900, md: 2200, lg: 5000, xl: 12000 } }) + ).toThrow(/reference tier/); + expect(() => Limits.checkFactors(Limits.PLACEHOLDER.factors)).not.toThrow(); + }); + test('the placeholder set satisfies both rules', () => { // It is not a pricing decision, but it has to be a coherent one, or // nothing downstream can be tested against it. @@ -83,7 +93,11 @@ describe('Configuration', () => { test('the environment overrides it, and is validated on the way in', () => { Env.init({ - BURN_LIMITS: JSON.stringify({ free: hours(12, 350, 1200), paid: hours(40, 1200, 4000) }) + BURN_LIMITS: JSON.stringify({ + free: hours(12, 350, 1200), + paid: hours(40, 1200, 4000), + factors: Limits.PLACEHOLDER.factors + }) }); Limits.reset(); expect(Limits.get().free.fiveHour).toBe(12 * HOUR); @@ -94,7 +108,11 @@ describe('Configuration', () => { // closest to the burn data, and a set that quietly stops binding is not // visible from the numbers. Env.init({ - BURN_LIMITS: JSON.stringify({ free: hours(10, 400, 1000), paid: hours(30, 900, 3000) }) + BURN_LIMITS: JSON.stringify({ + free: hours(10, 400, 1000), + paid: hours(30, 900, 3000), + factors: Limits.PLACEHOLDER.factors + }) }); Limits.reset(); expect(() => Limits.get()).toThrow(/never be reached/); diff --git a/packages/core/src/billing/limits.ts b/packages/core/src/billing/limits.ts index beb7c8cf..ce6f1de5 100644 --- a/packages/core/src/billing/limits.ts +++ b/packages/core/src/billing/limits.ts @@ -33,9 +33,49 @@ export namespace Limits { export type Allowances = z.infer; + /** + * What a size tier costs per second, times a thousand, on our own hardware. + * + * A tier buys a share of a card, so a bigger one spends more of something + * we paid for. These must be **superlinear in that share**: a small title + * asked to run at the top of the ladder has to cost what a whole card + * costs, or the ladder is gamed and the density that makes any of this + * priceable is theoretical. + * + * They do not apply on a caller's own hardware. See {@link Factors}. + */ + const SizeFactors = z.object({ + xs: z.number().int().positive(), + sm: z.number().int().positive(), + md: z.number().int().positive(), + lg: z.number().int().positive(), + xl: z.number().int().positive() + }); + + /** + * How much a running session costs per second, before concurrency. + * + * **Only on hardware we own.** The size factor prices a share of a card we + * bought; on somebody else's card there is no such share being spent, so a + * session there costs one unit a second whatever tier it asked for. Charging + * more for taking more of their own GPU would be a tax on hardware they paid + * for, which is the complaint this whole model is shaped to avoid. + * + * There is no hardware factor here yet, and its absence is deliberate rather + * than an oversight: nothing records which card a host has, so a table keyed + * on a model would be keyed on nothing. A faster card should cost more, and + * that starts with a column, not a number. + */ + export const Factors = z.object({ + size: SizeFactors + }); + + export type Factors = z.infer; + export const Config = z.object({ free: Allowances, - paid: Allowances + paid: Allowances, + factors: Factors }); export type Config = z.infer; @@ -59,6 +99,13 @@ export namespace Limits { fiveHour: 30 * 60 * 60, sevenDay: 900 * 60 * 60, thirtyDay: 3000 * 60 * 60 + }, + // Superlinear, and no more principled than that. `sm` is the reference + // and is 1 by definition; the rest roughly double per step so the shape + // is visible in tests. Real values come from what a card-hour costs us + // divided by the share a tier holds. + factors: { + size: { xs: 500, sm: 1000, md: 2200, lg: 5000, xl: 12000 } } }; @@ -108,10 +155,28 @@ export namespace Limits { } } + /** + * The reference tier costs exactly one unit a second, by definition. + * + * The unit *is* a second of a reference session, so a size factor that made + * `sm` anything other than 1 would silently redefine what every allowance + * means — the same stored number would be a different number of hours. + */ + export function checkFactors(factors: Factors): void { + if (factors.size.sm !== 1000) { + throw new VisibleError( + 'internal', + ErrorCodes.Server.INTERNAL_ERROR, + `the reference tier must cost exactly one unit a second (1000), not ${factors.size.sm} \u2014 it is what every allowance is denominated in` + ); + } + } + export function validate(config: unknown): Config { const parsed = Config.parse(config); check(parsed.free, 'free'); check(parsed.paid, 'paid'); + checkFactors(parsed.factors); return parsed; } diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts index 75f36419..a65a3135 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -664,7 +664,17 @@ export namespace Session { 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 }); + // The rate is fixed here, from what this run actually is: + // the size it holds, and whose card it holds it on. Both + // are settled before the run starts, which is what lets a + // person be told the cost before committing to it. + const box = await Box.fromID(moved.boxId); + await Burn.start({ + teamId: machine.teamId, + sessionId: moved.id, + tier: (box?.tier ?? 'sm') as Burn.Tier, + hostClass: machine.organisationId ? 'fleet' : 'byo' + }); } else if ( ACCRUING.includes(current.state as (typeof ACCRUING)[number]) && !ACCRUING.includes(moved.state as (typeof ACCRUING)[number])