mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
feat(core): price a run by the size it holds, on hardware we pay for
The rate was a constant. That was correct for everything the system can currently run and wrong the moment it can run anything else, because a tier buys a share of a card — so a bigger one on our own hardware is more of something we bought being spent, and a flat rate there sells a whole card for the price of a quarter of one. So a run's rate now comes from what the run is: its size tier, and whose hardware it sits on. On the caller's own hardware the tier changes nothing. 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 a GPU they bought is a tax on their own hardware, and not doing that is most of what this model is for. This exposed a bug in what went before. Resegmenting recomputed one shared rate and wrote it to every open stretch, which was harmless while all runs cost the same and would have quietly repriced an expensive run as whatever the last one to start was. Each stretch now keeps its own rate, which is also the more honest shape: a run's rate is a property of that run, and nothing about it changed because a sibling appeared or the clock ticked. The account's total is now the sum of what its runs cost rather than a count times one rate — an expensive run and a cheap one alongside it are not two of anything. Concurrency still lands exactly where it did, as there being more to add, and no run gets dearer because another started. There is no hardware factor yet and its absence is deliberate: 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. The reference tier is pinned at exactly one unit a second, checked rather than assumed. The unit is a second of a reference session, so moving it would silently redefine every allowance — the same stored number would mean a different number of hours.
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<typeof HostClass>;
|
||||
|
||||
export const Tier = z.enum(['xs', 'sm', 'md', 'lg', 'xl']);
|
||||
export type Tier = z.infer<typeof Tier>;
|
||||
|
||||
/**
|
||||
* 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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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);
|
||||
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<State> => {
|
||||
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.`
|
||||
);
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -33,9 +33,49 @@ export namespace Limits {
|
||||
|
||||
export type Allowances = z.infer<typeof Allowances>;
|
||||
|
||||
/**
|
||||
* 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<typeof Factors>;
|
||||
|
||||
export const Config = z.object({
|
||||
free: Allowances,
|
||||
paid: Allowances
|
||||
paid: Allowances,
|
||||
factors: Factors
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof Config>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user