@nimbus-sh/platform 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/diag-counters.d.ts +11 -0
- package/dist/diag-counters.d.ts.map +1 -1
- package/dist/diag-counters.js +5 -0
- package/dist/heavy-alloc-coord.d.ts +5 -0
- package/dist/heavy-alloc-coord.d.ts.map +1 -1
- package/dist/heavy-alloc-coord.js +12 -1
- package/dist/limits.d.ts +1 -6
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +7 -6
- package/dist/lost-call.d.ts +62 -0
- package/dist/lost-call.d.ts.map +1 -0
- package/dist/lost-call.js +67 -0
- package/dist/oom-classify.d.ts +14 -0
- package/dist/oom-classify.d.ts.map +1 -1
- package/dist/oom-classify.js +30 -0
- package/dist/retry.d.ts +49 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +59 -0
- package/dist/utf8.d.ts +11 -0
- package/dist/utf8.d.ts.map +1 -0
- package/dist/utf8.js +35 -0
- package/dist/w7-frame.d.ts +278 -6
- package/dist/w7-frame.d.ts.map +1 -1
- package/dist/w7-frame.js +657 -134
- package/dist/wave-writer.d.ts +372 -0
- package/dist/wave-writer.d.ts.map +1 -0
- package/dist/wave-writer.js +879 -0
- package/package.json +1 -1
- package/src/diag-counters.ts +15 -0
- package/src/heavy-alloc-coord.ts +15 -1
- package/src/limits.ts +8 -7
- package/src/lost-call.ts +80 -0
- package/src/oom-classify.ts +32 -0
- package/src/retry.ts +74 -0
- package/src/utf8.ts +31 -0
- package/src/w7-frame.ts +780 -149
- package/src/wave-writer.ts +1065 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nimbus-sh/platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "The measured Cloudflare platform truths Nimbus is built on \u2014 error taxonomy, limits tables, budget coordination, and wire-format primitives, with zero runtime dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
package/src/diag-counters.ts
CHANGED
|
@@ -120,6 +120,15 @@ export interface DiagCounters {
|
|
|
120
120
|
* Empty when the most recent batch had zero errors. */
|
|
121
121
|
errorsByModule: Record<string, string>;
|
|
122
122
|
};
|
|
123
|
+
/**
|
|
124
|
+
* File contents and stdin the session has handed its processes, cumulative
|
|
125
|
+
* since isolate start: the bytes of each supervisor envelope's answer as it
|
|
126
|
+
* leaves the session (worker session/supervisor-op.ts answerSupervisorOp)
|
|
127
|
+
* from readFile, readFileBytes, fsRead, fsReadRange, fsReadBatch, the read
|
|
128
|
+
* an fsAcquired carries, and cpReadStdin (a pipe's read ahead). A run's
|
|
129
|
+
* delta is what it was handed, which bounds what it can hold.
|
|
130
|
+
*/
|
|
131
|
+
supervisorAnsweredBytes: number;
|
|
123
132
|
/** Pipelined R2 race outcomes. All counts are cumulative since
|
|
124
133
|
* DO-isolate start. Per-tier cache statistics live in cache-stats. */
|
|
125
134
|
r2: {
|
|
@@ -141,6 +150,7 @@ const _counters: DiagCounters = {
|
|
|
141
150
|
inFlightRpcPayloadBytes: 0,
|
|
142
151
|
prefetchBundleBytes: 0,
|
|
143
152
|
prefetchCacheBytes: 0,
|
|
153
|
+
supervisorAnsweredBytes: 0,
|
|
144
154
|
installFacet: {
|
|
145
155
|
tarballsCompleted: 0,
|
|
146
156
|
cumulativeBytesDecoded: 0,
|
|
@@ -167,6 +177,11 @@ export function readDiagCounters(): DiagCounters {
|
|
|
167
177
|
return { ..._counters };
|
|
168
178
|
}
|
|
169
179
|
|
|
180
|
+
/** Count `bytes` the session answered a process's supervisor call with. */
|
|
181
|
+
export function recordSupervisorAnswer(bytes: number): void {
|
|
182
|
+
_counters.supervisorAnsweredBytes += bytes;
|
|
183
|
+
}
|
|
184
|
+
|
|
170
185
|
/** Set the install phase. */
|
|
171
186
|
export function setInstallPhase(p: InstallPhase): void {
|
|
172
187
|
_counters.installPhase = p;
|
package/src/heavy-alloc-coord.ts
CHANGED
|
@@ -82,12 +82,21 @@ export class SupervisorAllocationBudget {
|
|
|
82
82
|
return this._acquire(bytes, signal, false);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/** Reserve bytes now or not at all, driving the lifecycle as acquire does. */
|
|
86
|
+
tryAcquire(bytes: number): ResizableCreditLease | null {
|
|
87
|
+
const credit = this.credits.tryAcquire(bytes);
|
|
88
|
+
return credit ? this.wrap(credit, true) : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
85
91
|
private async _acquire(
|
|
86
92
|
bytes: number,
|
|
87
93
|
signal: AbortSignal | undefined,
|
|
88
94
|
drivesLifecycle: boolean,
|
|
89
95
|
): Promise<ResizableCreditLease> {
|
|
90
|
-
|
|
96
|
+
return this.wrap(await this.credits.acquire(bytes, signal), drivesLifecycle);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private wrap(credit: ResizableCreditLease, drivesLifecycle: boolean): ResizableCreditLease {
|
|
91
100
|
if (drivesLifecycle) {
|
|
92
101
|
this.lifecycleHolders++;
|
|
93
102
|
if (!this.active) {
|
|
@@ -212,6 +221,11 @@ export function acquireSupervisorAllocation(
|
|
|
212
221
|
return supervisorAllocationBudget.acquire(bytes, signal);
|
|
213
222
|
}
|
|
214
223
|
|
|
224
|
+
/** Reserve bytes now if they can be granted without waiting, else null. */
|
|
225
|
+
export function tryAcquireSupervisorAllocation(bytes: number): ResizableCreditLease | null {
|
|
226
|
+
return supervisorAllocationBudget.tryAcquire(bytes);
|
|
227
|
+
}
|
|
228
|
+
|
|
215
229
|
/**
|
|
216
230
|
* Reserve bytes for a filesystem READ. Takes the same byte credit as any
|
|
217
231
|
* other owner but does not shrink the disposable VFS cache, which reads are
|
package/src/limits.ts
CHANGED
|
@@ -66,13 +66,6 @@ export const HYDRATION_DEADLINE_MS = 30_000;
|
|
|
66
66
|
*/
|
|
67
67
|
export const SQLITE_MAX_STATEMENT_BYTES = 100 * 1024;
|
|
68
68
|
|
|
69
|
-
/**
|
|
70
|
-
* Maximum bound parameters per query. The easiest cap to hit accidentally:
|
|
71
|
-
* a batched insert of more than 100/columns rows in one statement breaches
|
|
72
|
-
* it.
|
|
73
|
-
*/
|
|
74
|
-
export const SQLITE_MAX_BOUND_PARAMETERS = 100;
|
|
75
|
-
|
|
76
69
|
/**
|
|
77
70
|
* Maximum bytes of one string, BLOB, or table ROW — the bound is per ROW,
|
|
78
71
|
* key length included, not per value. The measured single-value ceiling is
|
|
@@ -196,6 +189,14 @@ export const SUPERVISOR_HEAP_CEILING_BYTES = 64 * 1024 * 1024;
|
|
|
196
189
|
// of a facet's measured ~208-256 MiB, the rest left to the program.
|
|
197
190
|
export const FACET_OWN_WRITE_MEMORY_BYTES = 32 * 1024 * 1024;
|
|
198
191
|
|
|
192
|
+
// A WASI process's copy of the namespace and of the files it reads
|
|
193
|
+
// (core runtime/wasi/resident-filesystem.ts): held in its facet's heap beside
|
|
194
|
+
// the guest's linear memory, and never past this. Names are listed and bytes
|
|
195
|
+
// fetched only as the program asks for them, so the cap bounds a tree walker
|
|
196
|
+
// or a program that reads much, not what a short run holds. Past it the store
|
|
197
|
+
// holds nothing more and the session answers.
|
|
198
|
+
export const WASI_RESIDENT_STORE_BYTES = 32 * 1024 * 1024;
|
|
199
|
+
|
|
199
200
|
// Shared allowance for transient allocations in the supervisor DO. With the
|
|
200
201
|
// VFS LRU shrunk to 8 MiB during an active reservation, 40 MiB of admitted
|
|
201
202
|
// payload plus the 9 MiB bundle baseline stays below the 64 MiB soft ceiling
|
package/src/lost-call.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lost-call.ts — the one policy for a call that never reaches the session.
|
|
3
|
+
*
|
|
4
|
+
* The platform drops calls into a Durable Object now and then, and the
|
|
5
|
+
* caller cannot tell a call that never arrived from one that is slow:
|
|
6
|
+
* - reads, SupervisorRPC → session, under three concurrent sessions
|
|
7
|
+
* (2026-09-28): 11 of 521 read batches never reached the session and
|
|
8
|
+
* were still pending 110-560 s later; every read that answered did so
|
|
9
|
+
* within 5 s;
|
|
10
|
+
* - write waves, a facet's writeBatchStream (2026-10-06, two next.js
|
|
11
|
+
* clones): the transport read the stream's first ~1 MiB within 0.1 s
|
|
12
|
+
* and nothing more, while the session held no stream, credit,
|
|
13
|
+
* allocation or transaction for it and kept committing other waves;
|
|
14
|
+
* healthy, with 8 producers saturating one session, the longest gap
|
|
15
|
+
* between a wave's reads was 2.5 s and the slowest wave 5.3 s;
|
|
16
|
+
* - calls that fail at once, "Network connection lost." (`retryable`),
|
|
17
|
+
* which classifyDoCall names.
|
|
18
|
+
*
|
|
19
|
+
* Detection and timings live here; the recovery depends on what a second
|
|
20
|
+
* delivery would do. A read is idempotent, so it is hedged: the same call
|
|
21
|
+
* on a fresh stub, the first answer taken (fabric do-calls `hedgeAfterMs`).
|
|
22
|
+
* A write wave carries a stream the first attempt consumes, so it cannot be
|
|
23
|
+
* hedged: it is re-encoded and re-sent under a fence (`WaveFence`), and the
|
|
24
|
+
* session refuses an attempt older than one it has seen from the same
|
|
25
|
+
* writer, so a late original never applies over its re-send.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { classifyDoCall, isRetryableDoCall } from './oom-classify.js';
|
|
29
|
+
|
|
30
|
+
/** An idempotent call unanswered this long is hedged on a fresh stub. */
|
|
31
|
+
export const LOST_CALL_HEDGE_AFTER_MS = 5_000;
|
|
32
|
+
|
|
33
|
+
/** A stream nothing has read for this long, before its end, never reached the session. */
|
|
34
|
+
export const LOST_STREAM_STALL_MS = 10_000;
|
|
35
|
+
|
|
36
|
+
/** A call unanswered this long after its stream ended is taken as lost. */
|
|
37
|
+
export const LOST_STREAM_ANSWER_MS = 20_000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How long a write-wave epoch the session issued admits attempts. Anything
|
|
41
|
+
* sent under an epoch the session does not know, or one past this, is
|
|
42
|
+
* refused: a call lost for longer than this can only be refused, never
|
|
43
|
+
* admitted. A writer opens a new epoch once half of this has passed.
|
|
44
|
+
*/
|
|
45
|
+
export const WAVE_EPOCH_TTL_MS = 10 * 60_000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Waits before each re-send of a lost non-idempotent call (±25% jitter):
|
|
49
|
+
* ~42 s in all, to outlast a coordinator queue deep enough to shed. Its
|
|
50
|
+
* length is the bound on re-sends.
|
|
51
|
+
*/
|
|
52
|
+
export const LOST_CALL_RESEND_BACKOFF_MS: readonly number[] = [250, 1_000, 3_000, 6_000, 12_000, 20_000];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether `error` is the platform losing a fenced call: a dropped
|
|
56
|
+
* connection, a replaced isolate, a storage reset, or an object that shed
|
|
57
|
+
* it. Overloaded is included for fenced re-sends only: the platform's advice
|
|
58
|
+
* is not to retry an overloaded object, but these are few and backed off,
|
|
59
|
+
* and npm measured one re-send recover a 119-package install that one shed
|
|
60
|
+
* cost 31 packages (do-calls' unfenced verbs still never retry it).
|
|
61
|
+
*/
|
|
62
|
+
export function isLostFencedCall(error: unknown): boolean {
|
|
63
|
+
const kind = classifyDoCall(error);
|
|
64
|
+
return isRetryableDoCall(kind) || kind === 'overloaded';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The attributes a lost call is logged and traced under, by every caller. */
|
|
68
|
+
export function lostCallAttributes(lost: {
|
|
69
|
+
operation: string;
|
|
70
|
+
attempt: number;
|
|
71
|
+
of: number;
|
|
72
|
+
reason: string;
|
|
73
|
+
}): Record<string, string | number> {
|
|
74
|
+
return {
|
|
75
|
+
'do_call.operation': lost.operation,
|
|
76
|
+
'do_call.attempts': lost.attempt,
|
|
77
|
+
'do_call.max_attempts': lost.of,
|
|
78
|
+
'do_call.lost_reason': lost.reason,
|
|
79
|
+
};
|
|
80
|
+
}
|
package/src/oom-classify.ts
CHANGED
|
@@ -209,6 +209,38 @@ export function isTransientDoReset(input: unknown): boolean {
|
|
|
209
209
|
return false;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* The platform's own word that the actor a call reached was reset or killed:
|
|
214
|
+
* its `durableObjectReset` flag, or one of its termination sentences, whole.
|
|
215
|
+
* Text a program wrote never counts, however it reads: a process whose host
|
|
216
|
+
* this matches is ended (fabric process-host.ts HostLoss).
|
|
217
|
+
*/
|
|
218
|
+
export function isHostReset(input: unknown): boolean {
|
|
219
|
+
if (typeof input === 'object' && input !== null && Reflect.get(input, 'durableObjectReset') === true) return true;
|
|
220
|
+
const message = readMessage(input).trim().replace(/^broken\.\w+;\s*jsg\.\w+:\s*/, '');
|
|
221
|
+
return HOST_TERMINATIONS.some((signature) => signature.test(message));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const HOST_TERMINATIONS: readonly RegExp[] = [
|
|
225
|
+
/^Durable Object's isolate exceeded its memory limit (?:and was reset\.|due to )/,
|
|
226
|
+
/^Worker (?:has )?exceeded memory limit\.$/,
|
|
227
|
+
/^(?:Python )?Worker exceeded CPU time limit\.?$/,
|
|
228
|
+
/^Durable Object exceeded its CPU time limit and was reset\.$/,
|
|
229
|
+
/^Durable Object reset because its code was updated\.$/,
|
|
230
|
+
/^Internal error (?:in|while starting up) Durable Object storage caused object to be reset; reference = \S+$/,
|
|
231
|
+
/^Durable Object storage operation exceeded timeout which caused the object to be reset\.$/,
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A failure the platform reports without a cause: workerd's "internal error;
|
|
236
|
+
* reference = <id>", whose reference only Cloudflare can look up. The error
|
|
237
|
+
* may carry `durableObjectReset` (the object it reached was reset), and
|
|
238
|
+
* nothing else; the caller has to name what it was doing.
|
|
239
|
+
*/
|
|
240
|
+
export function isUnexplainedPlatformError(input: unknown): boolean {
|
|
241
|
+
return /^internal error; reference = \S+$/i.test(readMessage(input).trim());
|
|
242
|
+
}
|
|
243
|
+
|
|
212
244
|
/**
|
|
213
245
|
* Workerd shed the call because the target Durable Object's input-gate
|
|
214
246
|
* queue was too deep or too old: "Durable Object is overloaded."
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* retry.ts — the one retry policy: the loop that tries an operation again
|
|
3
|
+
* when its outcome is worth another try, and the wait between tries.
|
|
4
|
+
*
|
|
5
|
+
* Each caller names what is worth another try and how far apart, because
|
|
6
|
+
* that is what differs and was measured per transport:
|
|
7
|
+
* - the npm registry (npm/registry-retry.ts): a 5xx or a request that
|
|
8
|
+
* never answered, 500/1500/4500 ms;
|
|
9
|
+
* - a git server (git/pack/transport.ts): an idempotent request that
|
|
10
|
+
* failed in transit, stalled, or met a transient edge status, 1/3 s;
|
|
11
|
+
* - a lost write wave (lost-call.ts LOST_CALL_RESEND_BACKOFF_MS).
|
|
12
|
+
* Every wait is its schedule's entry ±25%, uniformly, so concurrent callers
|
|
13
|
+
* that failed together do not try again together.
|
|
14
|
+
*
|
|
15
|
+
* Self-contained, both functions: a facet that cannot import carries them
|
|
16
|
+
* by source (fn.toString(); the npm install facet's preamble does).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The wait before re-try `attempt` (0-based, the try that just failed):
|
|
21
|
+
* `schedule[attempt]`, its last entry once past the end, ±25%.
|
|
22
|
+
*/
|
|
23
|
+
export function retryDelayMs(schedule: readonly number[], attempt: number): number {
|
|
24
|
+
const base = schedule[Math.min(attempt, schedule.length - 1)] ?? 0;
|
|
25
|
+
return Math.max(0, Math.round(base + (Math.random() * 2 - 1) * base * 0.25));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An attempt's outcome: what it returned, or what it threw. */
|
|
29
|
+
export type RetryOutcome<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: unknown };
|
|
30
|
+
|
|
31
|
+
export interface RetryPolicy<T> {
|
|
32
|
+
/** How many times to try again after the first try. */
|
|
33
|
+
readonly retries: number;
|
|
34
|
+
/** The waits before each re-try, for retryDelayMs. */
|
|
35
|
+
readonly schedule: readonly number[];
|
|
36
|
+
/** Why `outcome` is worth another try, or null to keep it. Not asked of the last try. */
|
|
37
|
+
retryReason(outcome: RetryOutcome<T>): string | null;
|
|
38
|
+
/** A returned value given up for another try (its body cancelled, its stub disposed). */
|
|
39
|
+
discard?(value: T): unknown;
|
|
40
|
+
/** Each re-try, 1-based, with its wait and why. */
|
|
41
|
+
onRetry?(retry: number, of: number, delayMs: number, reason: string): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `attempt(n)` for n = 0, 1, … until its outcome is not worth another try
|
|
46
|
+
* or the policy's re-tries are spent; then that outcome: its value
|
|
47
|
+
* returned, its error thrown.
|
|
48
|
+
*/
|
|
49
|
+
export async function retrying<T>(attempt: (n: number) => Promise<T>, policy: RetryPolicy<T>): Promise<T> {
|
|
50
|
+
for (let n = 0; ; n++) {
|
|
51
|
+
let outcome: RetryOutcome<T>;
|
|
52
|
+
try {
|
|
53
|
+
outcome = { ok: true, value: await attempt(n) };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
outcome = { ok: false, error };
|
|
56
|
+
}
|
|
57
|
+
const reason = n < policy.retries ? policy.retryReason(outcome) : null;
|
|
58
|
+
if (reason === null) {
|
|
59
|
+
if (outcome.ok) return outcome.value;
|
|
60
|
+
throw outcome.error;
|
|
61
|
+
}
|
|
62
|
+
if (outcome.ok && policy.discard) {
|
|
63
|
+
try {
|
|
64
|
+
await policy.discard(outcome.value);
|
|
65
|
+
} catch {
|
|
66
|
+
// It is being given up either way.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const delayMs = retryDelayMs(policy.schedule, n);
|
|
70
|
+
policy.onRetry?.(n + 1, policy.retries, delayMs, reason);
|
|
71
|
+
// The executor form: this package's lib (ES2022) has no Promise.withResolvers.
|
|
72
|
+
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/utf8.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* utf8.ts — the UTF-8 length of a string, without encoding it.
|
|
3
|
+
*
|
|
4
|
+
* Bounds on names and payloads are in bytes, and the hot ones (every path a
|
|
5
|
+
* W7 wave or a listing page carries) were measured by encoding the string
|
|
6
|
+
* into a new buffer only to read its length. This counts the same bytes
|
|
7
|
+
* TextEncoder would write: one to four per code point, and three for a
|
|
8
|
+
* lone surrogate, which it writes as U+FFFD.
|
|
9
|
+
*/
|
|
10
|
+
export function utf8Length(text: string): number {
|
|
11
|
+
let bytes = text.length;
|
|
12
|
+
for (let index = 0; index < text.length; index++) {
|
|
13
|
+
const unit = text.charCodeAt(index);
|
|
14
|
+
if (unit < 0x80) continue;
|
|
15
|
+
if (unit < 0x800) {
|
|
16
|
+
bytes += 1;
|
|
17
|
+
} else if (unit >= 0xd800 && unit <= 0xdbff && index + 1 < text.length) {
|
|
18
|
+
const next = text.charCodeAt(index + 1);
|
|
19
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
20
|
+
// A pair: two units, four bytes.
|
|
21
|
+
bytes += 2;
|
|
22
|
+
index++;
|
|
23
|
+
} else {
|
|
24
|
+
bytes += 2;
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
bytes += 2;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return bytes;
|
|
31
|
+
}
|