Skip to content

Commit ea101e7

Browse files
Brooooooklynclaude
andauthored
feat(api): per-project cpuMs override with plan ceiling (#263)
* feat(api): per-project cpuMs override with plan ceiling (bump framework) Honors the new manifest field limits.cpuMs (void.json worker.limits.cpu_ms, #299) when building routing dispatch limits: - tiers.ts: PlanLimits.maxCpuMsPerRequest, the highest cpuMs a project may request (free 10, solo/pro/sponsored 30_000, custom 0 = no ceiling) - deploy ingest: validate manifest.limits shape; reject an over-ceiling cpuMs with 400 naming both numbers, before the claim transaction - routing.ts: shared computeDispatchLimits replaces the duplicated inline expressions at deploy go-live and rollback; byte-identical output for manifests without limits - rollback clamps to the current plan ceiling instead of rejecting, and an out-of-domain stored cpuMs (0, negative, fractional - storable through the pre-validation API skew window) falls back to the plan default instead of erasing the cap - dispatch unchanged: routing.limits was already applied verbatim Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DF8p6MCmgiE7e3LM1J8EQV * fix(api): replace zero-width spaces in comments with backslash escapes `vp lint` has failed on main since the vite+ 0.3.0 bump (#262): the newer oxlint enforces no-irregular-whitespace, which flags six U+200B zero-width spaces added in #89. Every one sits inside a quoted `*/`, where it stops the sequence from closing the enclosing comment. Deleting them is not safe. Four are inside `/** */` docstrings, where a bare `*/` truncates the comment and breaks the file. Those use `*\/`, which the tokenizer does not treat as a terminator. The remaining two are inside `//` line comments, where `*/` needs no escape at all. Unblocks CI for this PR and every branch cut after #262. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Grrh3fMQxnc3MKfJWKg7Vv * chore(api): track the framework submodule at void CLI 0.10.12 The rebased submodule pointer moves the framework from 0.10.10 to 0.10.12, and both fallback CLI constants must follow it. They only apply to repos with no local `void` dependency, so drift never shows up in an ordinary build — `build-manager.test.ts` is what catches it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Grrh3fMQxnc3MKfJWKg7Vv --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0a1e32f commit ea101e7

13 files changed

Lines changed: 372 additions & 28 deletions

File tree

‎framework‎

Submodule framework updated 232 files

‎packages/api/container/build-agent.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const DO_POST_TIMEOUT_MS = 10000;
7373
// build-manager test asserts against the framework submodule — this file is
7474
// plain JS copied into the container image, so it can't import that constant
7575
// and the test mirrors the literal instead.
76-
export const DEFAULT_VOID_CLI_VERSION = '0.10.10';
76+
export const DEFAULT_VOID_CLI_VERSION = '0.10.12';
7777

7878
// --- source-download bounds --------------------------------------------------
7979
//

‎packages/api/src/build-manager.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ export function withDeadline<T>(work: Promise<T>, ms: number, reason: string): P
268268
// against) so a moving `void@latest` can't change deploy behavior with no repo
269269
// diff. The comment alone let this sit a full minor behind for the whole life of
270270
// the feature, so `build-manager.test.ts` now asserts the equality instead.
271-
export const PINNED_VOID_CLI_VERSION = '0.10.10';
271+
export const PINNED_VOID_CLI_VERSION = '0.10.12';
272272

273273
// Ceiling on how long a queued build may wait for the just-in-time branch-head
274274
// freshness recheck to succeed. While the GitHub compare API is unreachable

‎packages/api/src/routes/deploy.ts‎

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import { presignAssetPut } from '../r2-presign.ts';
7373
import {
7474
buildRoutingJson,
7575
buildStaticRoutingJson,
76+
computeDispatchLimits,
7677
projectAccountKey,
7778
writeProjectAccountEntry,
7879
} from '../routing.ts';
@@ -648,6 +649,29 @@ export function validateManifestShape(parsed: unknown): string | null {
648649
}
649650
}
650651

652+
// Per-project worker limit overrides (void.json `worker.limits`). Shape-only
653+
// here; the plan ceiling (`maxCpuMsPerRequest`) is enforced by the deploy
654+
// handler once the authenticated user's plan is in scope.
655+
if (manifest.limits !== undefined) {
656+
if (
657+
manifest.limits == null ||
658+
typeof manifest.limits !== 'object' ||
659+
Array.isArray(manifest.limits)
660+
) {
661+
return 'invalid manifest shape: limits must be an object';
662+
}
663+
const limits = manifest.limits as Record<string, unknown>;
664+
if (limits.cpuMs !== undefined) {
665+
if (
666+
typeof limits.cpuMs !== 'number' ||
667+
!Number.isSafeInteger(limits.cpuMs) ||
668+
limits.cpuMs < 1
669+
) {
670+
return 'invalid manifest shape: limits.cpuMs must be a positive integer';
671+
}
672+
}
673+
}
674+
651675
if (manifest.type !== undefined) {
652676
const allowed = ['void', 'spa', 'static', 'framework'];
653677
if (typeof manifest.type !== 'string' || !allowed.includes(manifest.type)) {
@@ -2203,6 +2227,24 @@ async function handleManifestOnlyDeploy(context: Context<ApiEnv>): Promise<Respo
22032227
);
22042228
}
22052229

2230+
// Plan ceiling on a requested per-project CPU limit. Rejected at ingest —
2231+
// before the precheck-and-claim transaction or any asset work — so an
2232+
// over-ceiling deploy fails fast with both numbers. Rollback deliberately
2233+
// clamps instead of rejecting (see `computeDispatchLimits`).
2234+
const requestedCpuMs = incomingManifest.limits?.cpuMs;
2235+
if (requestedCpuMs !== undefined) {
2236+
const maxCpuMs = getPlanLimits(user.plan).maxCpuMsPerRequest;
2237+
if (maxCpuMs > 0 && requestedCpuMs > maxCpuMs) {
2238+
return context.json(
2239+
{
2240+
event: 'error',
2241+
message: `limits.cpuMs ${requestedCpuMs} exceeds your plan maximum of ${maxCpuMs}`,
2242+
},
2243+
400,
2244+
);
2245+
}
2246+
}
2247+
22062248
// Validate the v2 asset manifest. Each entry MUST carry a 32-hex blake3
22072249
// (storage key), 32-hex md5 (R2 PUT integrity check), and a non-negative
22082250
// integer size. We translate the v2 entries into the v1 shape
@@ -3878,15 +3920,7 @@ async function handleManifestOnlyDeploy(context: Context<ApiEnv>): Promise<Respo
38783920
// Routing switch
38793921
const existingRouting = await context.env.ROUTING.get(project.slug);
38803922
const existingEntry = existingRouting ? JSON.parse(existingRouting) : null;
3881-
const dispatchLimits =
3882-
planLimits.cpuMsPerRequest > 0 || planLimits.subRequestsPerInvocation > 0
3883-
? {
3884-
...(planLimits.cpuMsPerRequest > 0 ? { cpuMs: planLimits.cpuMsPerRequest } : {}),
3885-
...(planLimits.subRequestsPerInvocation > 0
3886-
? { subRequests: planLimits.subRequestsPerInvocation }
3887-
: {}),
3888-
}
3889-
: null;
3923+
const dispatchLimits = computeDispatchLimits(planLimits, manifest.limits);
38903924
const routingJson = buildRoutingJson({
38913925
workerName,
38923926
projectId,

‎packages/api/src/routes/projects.ts‎

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import {
4545
InvalidHyperdriveConnectionStringError,
4646
getDispatchWorkerProxyToken,
4747
} from '../provisioning/cloudflare-api.ts';
48-
import { buildRoutingJson, buildStaticRoutingJson } from '../routing.ts';
48+
import { buildRoutingJson, buildStaticRoutingJson, computeDispatchLimits } from '../routing.ts';
4949
import { tokenizeSql } from '../sql/tokenize.ts';
5050
import { getPlanLimits } from '../tiers.ts';
5151
import type { Build, DeployManifest } from '../types.ts';
@@ -300,10 +300,10 @@ type LineComment = Readonly<{
300300
* on data like `VALUES('DROP TABLE users')` and on `-- DROP TABLE`
301301
* in a line comment, which could block legitimate migrations.
302302
* 3. Block comments are intentionally NOT counted toward the pragma. A
303-
* docstring like `/* use -- void:allow-destructive to permit drops *​/`
303+
* docstring like `/* use -- void:allow-destructive to permit drops *\/`
304304
* is documentation, not authorization. They are also replaced with a
305305
* single space rather than deleted outright so that token boundaries
306-
* are preserved — `DROP/**​/TABLE` must not collapse to `DROPTABLE`,
306+
* are preserved — `DROP/**\/TABLE` must not collapse to `DROPTABLE`,
307307
* which would slip the destructive keyword regex entirely.
308308
* 4. The standalone bit on each line comment lets the caller require
309309
* that the destructive pragma occupies its own raw line. Trailing
@@ -312,7 +312,7 @@ type LineComment = Readonly<{
312312
*
313313
* Only the SQLite lexical forms are handled: `'...'` strings with `''`
314314
* escape, `"..."` / `` `...` `` / `[...]` identifier quotes with doubled
315-
* closing-quote escape, `-- line` comments, and `/* block *​/` comments.
315+
* closing-quote escape, `-- line` comments, and `/* block *\/` comments.
316316
*/
317317
function stripSqlStringsAndComments(sql: string): {
318318
code: string;
@@ -330,7 +330,7 @@ function stripSqlStringsAndComments(sql: string): {
330330
// Walk backwards from the `--` to the previous newline (or start of
331331
// input). The comment is "standalone" iff every character in that
332332
// raw-source prefix is whitespace — anything else (a SQL token, the
333-
// closing `*​/` of a block comment, a string literal, etc.) means the
333+
// closing `*/` of a block comment, a string literal, etc.) means the
334334
// pragma is sharing its line with content and must not authorize.
335335
let standalone = true;
336336
for (let k = seg.start - 1; k >= 0; k--) {
@@ -350,7 +350,7 @@ function stripSqlStringsAndComments(sql: string): {
350350
}
351351
case 'block-comment':
352352
// Replace the block comment with a single space so token boundaries
353-
// are preserved — `DROP/**​/TABLE` must NOT collapse to `DROPTABLE`,
353+
// are preserved — `DROP/**/TABLE` must NOT collapse to `DROPTABLE`,
354354
// which would slip past the destructive regex entirely. The space
355355
// is sufficient because every destructive pattern requires `\b` /
356356
// `\s+` between keywords.
@@ -698,15 +698,6 @@ const app = new Hono<ApiEnv>()
698698
}
699699

700700
const planLimits = getPlanLimits(context.get('user').plan);
701-
const dispatchLimits =
702-
planLimits.cpuMsPerRequest > 0 || planLimits.subRequestsPerInvocation > 0
703-
? {
704-
...(planLimits.cpuMsPerRequest > 0 ? { cpuMs: planLimits.cpuMsPerRequest } : {}),
705-
...(planLimits.subRequestsPerInvocation > 0
706-
? { subRequests: planLimits.subRequestsPerInvocation }
707-
: {}),
708-
}
709-
: null;
710701

711702
// The entire rollback cutover runs inside a single row-locked tx so
712703
// concurrent rollbacks / applies / renames serialize and no external
@@ -818,6 +809,12 @@ const app = new Hono<ApiEnv>()
818809
const manifest: DeployManifest | null = target.manifest
819810
? JSON.parse(target.manifest)
820811
: null;
812+
// Effective dispatch limits honor the TARGET manifest's per-project
813+
// override, clamped to the CURRENT plan ceiling — a rollback under a
814+
// downgraded plan clamps, never rejects. Computed here (pure CPU, no
815+
// I/O added inside the lock) because the manifest is re-read inside
816+
// the lock; `planLimits` itself is resolved before the tx.
817+
const dispatchLimits = computeDispatchLimits(planLimits, manifest?.limits);
821818
// Rollback external-write pass depends only on authoritative
822819
// persistent sources (pg deployments table for queue activation
823820
// history; target's manifest + unconditional DO reconciliation

‎packages/api/src/routing.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getDomainsByProjectIds, getProjectsByUserId, getUser, type Db } from './db.ts';
2+
import type { PlanLimits } from './tiers.ts';
23
import type { AssetConfig, DeployManifest } from './types.ts';
34

45
/**
@@ -168,6 +169,54 @@ export async function setProjectsRoutingSuspension(
168169
}
169170
}
170171

172+
/**
173+
* Compute the `dispatchLimits` blob `buildRoutingJson` embeds in a routing
174+
* entry, honoring an optional per-project `manifest.limits.cpuMs` override.
175+
*
176+
* - Override present: clamp to the plan's `maxCpuMsPerRequest` when that
177+
* ceiling is non-zero (rollback under a downgraded plan clamps, never
178+
* rejects — ingest is where over-ceiling requests get a 400), otherwise
179+
* pass it through verbatim.
180+
* - No override: fall back to the plan default `cpuMsPerRequest` (0 = no
181+
* cpuMs key), byte-identical in shape to the historical inline expression.
182+
* - An override that is not a safe integer >= 1 is treated as absent and
183+
* falls back to the plan default. A rollback replays a stored manifest,
184+
* and a manifest stored before ingest validated `limits` can carry an
185+
* out-of-domain value that must not erase or corrupt the plan cap.
186+
* - `subRequests` always comes from the plan (0 = no key).
187+
*
188+
* Returns `null` when neither limit is set so the routing entry omits the
189+
* `limits` key entirely.
190+
*/
191+
export function computeDispatchLimits(
192+
planLimits: PlanLimits,
193+
manifestLimits: DeployManifest['limits'] | null | undefined,
194+
): { cpuMs?: number; subRequests?: number } | null {
195+
const rawRequested = manifestLimits?.cpuMs;
196+
const requested =
197+
typeof rawRequested === 'number' && Number.isSafeInteger(rawRequested) && rawRequested >= 1
198+
? rawRequested
199+
: undefined;
200+
const max = planLimits.maxCpuMsPerRequest;
201+
const cpuMs =
202+
requested !== undefined
203+
? max > 0
204+
? Math.min(requested, max)
205+
: requested
206+
: planLimits.cpuMsPerRequest > 0
207+
? planLimits.cpuMsPerRequest
208+
: undefined;
209+
const subRequests =
210+
planLimits.subRequestsPerInvocation > 0 ? planLimits.subRequestsPerInvocation : undefined;
211+
if (!cpuMs && !subRequests) {
212+
return null;
213+
}
214+
return {
215+
...(cpuMs ? { cpuMs } : {}),
216+
...(subRequests ? { subRequests } : {}),
217+
};
218+
}
219+
171220
export function buildRoutingJson(opts: {
172221
workerName: string;
173222
projectId: string;

‎packages/api/src/sql/tokenize.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*
1010
* Lexical forms handled (SQLite / drizzle-kit SQLite output):
1111
* - `-- line` line comments (terminate at `\n` or EOF)
12-
* - `/* block *​/` block comments (tolerate unterminated — span to EOF)
12+
* - `/* block *\/` block comments (tolerate unterminated — span to EOF)
1313
* - `'...'` string literals with `''` doubled-quote escape
1414
* - `"..."` / `` `...` `` identifier quotes with `""` / `` `` `` escape
1515
* - `[...]` bracket identifiers, NO `]]` escape — a single

‎packages/api/src/tiers.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export type PlanLimits = Readonly<{
55
requestsPerMonth: number;
66
/** Max CPU milliseconds per request (0 = unlimited) */
77
cpuMsPerRequest: number;
8+
/** Highest cpuMs a project may request via its deploy manifest (0 = no ceiling) */
9+
maxCpuMsPerRequest: number;
810
/** Max sub-requests per invocation (0 = unlimited) */
911
subRequestsPerInvocation: number;
1012
/** Max deploys per day (0 = unlimited) */
@@ -35,6 +37,7 @@ export const PLAN_LIMITS: Record<PlanId, PlanLimits> = {
3537
free: {
3638
requestsPerMonth: 3_000_000,
3739
cpuMsPerRequest: 10,
40+
maxCpuMsPerRequest: 10,
3841
subRequestsPerInvocation: 50,
3942
deploysPerDay: 100,
4043
d1StorageBytes: 1 * GB,
@@ -51,6 +54,7 @@ export const PLAN_LIMITS: Record<PlanId, PlanLimits> = {
5154
solo: {
5255
requestsPerMonth: 10_000_000,
5356
cpuMsPerRequest: 30,
57+
maxCpuMsPerRequest: 30_000,
5458
subRequestsPerInvocation: 50,
5559
deploysPerDay: 500,
5660
d1StorageBytes: 5 * GB,
@@ -66,6 +70,7 @@ export const PLAN_LIMITS: Record<PlanId, PlanLimits> = {
6670
pro: {
6771
requestsPerMonth: 100_000_000,
6872
cpuMsPerRequest: 50,
73+
maxCpuMsPerRequest: 30_000,
6974
subRequestsPerInvocation: 1000,
7075
deploysPerDay: 3000,
7176
d1StorageBytes: 10 * GB,
@@ -81,6 +86,7 @@ export const PLAN_LIMITS: Record<PlanId, PlanLimits> = {
8186
sponsored: {
8287
requestsPerMonth: 0,
8388
cpuMsPerRequest: 50,
89+
maxCpuMsPerRequest: 30_000,
8490
subRequestsPerInvocation: 1000,
8591
deploysPerDay: 0,
8692
d1StorageBytes: 0,
@@ -96,6 +102,7 @@ export const PLAN_LIMITS: Record<PlanId, PlanLimits> = {
96102
custom: {
97103
requestsPerMonth: 0,
98104
cpuMsPerRequest: 0,
105+
maxCpuMsPerRequest: 0,
99106
subRequestsPerInvocation: 0,
100107
deploysPerDay: 0,
101108
d1StorageBytes: 0,

‎packages/api/src/types.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,13 @@ export type DeployManifest = Readonly<{
218218
workerMain?: string;
219219
compatibilityDate?: string;
220220
compatibilityFlags?: ReadonlyArray<string>;
221+
/**
222+
* Per-project worker limit overrides (void.json `worker.limits`, snake_case
223+
* `cpu_ms` on the config surface — camelCase here like other manifest
224+
* fields). Ingest rejects a `cpuMs` above the plan's `maxCpuMsPerRequest`;
225+
* rollback clamps instead (see `computeDispatchLimits`).
226+
*/
227+
limits?: Readonly<{ cpuMs?: number }>;
221228
hashedAssetsPrefix?: string;
222229
headerRules?: ReadonlyArray<HeaderRule>;
223230
redirectRules?: ReadonlyArray<RedirectRule>;

0 commit comments

Comments
 (0)