@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
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wave-writer.ts — a producer's writes into the session, as W7 waves.
|
|
3
|
+
*
|
|
4
|
+
* The one W7 producer: git's network facet (a clone's files, fetch and
|
|
5
|
+
* pull's objects and refs), npm's install facet (package files), and the
|
|
6
|
+
* session's own bulk writes (npm bin shims, the clang sysroot). Each write
|
|
7
|
+
* is a record: a file, a link, a directory, or a removal. The writer buffers
|
|
8
|
+
* records into a wave and publishes the wave through one writeBatchStream()
|
|
9
|
+
* call. A wave closes before it would pass W7's owned-path bounds (count and
|
|
10
|
+
* bytes: files, removals and the directories above them, up to the root) or
|
|
11
|
+
* its byte budget; a file larger than the budget travels in a wave of its
|
|
12
|
+
* own, and one streamed from a source is never held whole.
|
|
13
|
+
*
|
|
14
|
+
* Pipelining: one wave is in flight while the next one buffers. A wave
|
|
15
|
+
* starts only once its predecessor has published, so a producer waits only
|
|
16
|
+
* when it fills a second wave, and a failed wave is the last this writer
|
|
17
|
+
* sends: the failure names its wave, and every later call rejects with it.
|
|
18
|
+
* Waves publish in order, so a record written after another is durable only
|
|
19
|
+
* if that one is: a completion marker written last proves what came before.
|
|
20
|
+
*
|
|
21
|
+
* Lost transport (lost-call.ts, the one policy for it): a wave whose call
|
|
22
|
+
* failed before the session answered (isLostFencedCall), or that nothing
|
|
23
|
+
* read for LOST_STREAM_STALL_MS before its end, or that stayed unanswered
|
|
24
|
+
* LOST_STREAM_ANSWER_MS after it, is sent again after a backoff, at most
|
|
25
|
+
* LOST_CALL_RESEND_BACKOFF_MS.length times. The abandoned attempt's stream
|
|
26
|
+
* is errored so it reads nothing more, and every attempt carries its fence
|
|
27
|
+
* (writer, wave, attempt): the session refuses an attempt older than one it
|
|
28
|
+
* has seen, so a late original never applies over its re-send.
|
|
29
|
+
* Re-sending is safe: a wave is the same paths and bytes, replacing. A shed
|
|
30
|
+
* wave never ran; the platform's advice is not to retry an overloaded
|
|
31
|
+
* object, but these waves are few and backed off, and npm measured the
|
|
32
|
+
* re-send recover a 119-package install that otherwise lost 31 packages to
|
|
33
|
+
* one shed. A wave the session answered (ok: false) is its verdict and is
|
|
34
|
+
* never retried, nor is a wave with a streamed source (its source is spent).
|
|
35
|
+
*
|
|
36
|
+
* Fault domains: with `failPerOwner`, a record's `meta` is its owner (an
|
|
37
|
+
* npm package), and a failed wave whose records all have owners fails those
|
|
38
|
+
* owners (their later records reject, their buffered ones are not sent)
|
|
39
|
+
* while the writer goes on for the rest. Otherwise, and for a failed wave
|
|
40
|
+
* carrying anything unowned (a directory or removal record, the pin, a
|
|
41
|
+
* record without meta), the failed wave is the last one sent. A write that
|
|
42
|
+
* supersedes a buffered record (the same path, written again before a cut)
|
|
43
|
+
* settles it: the wave carrying the later write publishes or fails the
|
|
44
|
+
* owners of both. An owner is published once every wave that carried its
|
|
45
|
+
* writes has (`published`).
|
|
46
|
+
*
|
|
47
|
+
* Admitting a record costs its own new directories, never a recount of the
|
|
48
|
+
* wave: the owned set grows as records arrive, and a directory chain walk
|
|
49
|
+
* stops at the first directory already owned (whose chain is owned).
|
|
50
|
+
* Records may be written concurrently (an install writes several packages
|
|
51
|
+
* at once): each is admitted and buffered in call order.
|
|
52
|
+
*
|
|
53
|
+
* Several writers may publish into one session at once (a clone's parallel
|
|
54
|
+
* producers); each is its own stream, and the session takes them
|
|
55
|
+
* concurrently.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import {
|
|
59
|
+
encodeWriteBatch,
|
|
60
|
+
encodeWriteBatchStream,
|
|
61
|
+
W7_MAX_OWNED_PATH_BYTES,
|
|
62
|
+
W7_MAX_PATHS_PER_BATCH,
|
|
63
|
+
w7ChunkCount,
|
|
64
|
+
w7Chunks,
|
|
65
|
+
type BatchChunkEntry,
|
|
66
|
+
type BatchInodeEntry,
|
|
67
|
+
type BatchStreamEntry,
|
|
68
|
+
type BatchWritePayload,
|
|
69
|
+
} from './w7-frame.js';
|
|
70
|
+
import {
|
|
71
|
+
LOST_CALL_RESEND_BACKOFF_MS,
|
|
72
|
+
LOST_STREAM_ANSWER_MS,
|
|
73
|
+
LOST_STREAM_STALL_MS,
|
|
74
|
+
WAVE_EPOCH_TTL_MS,
|
|
75
|
+
isLostFencedCall,
|
|
76
|
+
lostCallAttributes,
|
|
77
|
+
} from './lost-call.js';
|
|
78
|
+
import { disposeRpcResource } from './rpc-dispose.js';
|
|
79
|
+
import { retryDelayMs } from './retry.js';
|
|
80
|
+
import { utf8Length } from './utf8.js';
|
|
81
|
+
|
|
82
|
+
/** Paths a wave holds back from W7's bound, for its pinned marker and the marker's directories. */
|
|
83
|
+
export const WAVE_PATHS = W7_MAX_PATHS_PER_BATCH - 8;
|
|
84
|
+
export const WAVE_PATH_BYTES = W7_MAX_OWNED_PATH_BYTES - 4 * 1024;
|
|
85
|
+
/** Buffered content bytes that close a wave. */
|
|
86
|
+
export const WAVE_BYTES = 4 * 1024 * 1024;
|
|
87
|
+
/** The pieces a wave encoded whole crosses its transport in. */
|
|
88
|
+
const SEND_SLICE_BYTES = 1024 * 1024;
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Which attempt of which wave under which writer epoch a stream is. The
|
|
93
|
+
* session issued the epoch (openWaveWriter) and refuses, by default, an
|
|
94
|
+
* attempt under an epoch it does not hold open, or older than one it has
|
|
95
|
+
* admitted under it: an attempt the writer gave up on never applies after
|
|
96
|
+
* its re-send, however late it arrives.
|
|
97
|
+
*/
|
|
98
|
+
export interface WaveFence {
|
|
99
|
+
writer: string;
|
|
100
|
+
wave: number;
|
|
101
|
+
attempt: number;
|
|
102
|
+
/**
|
|
103
|
+
* A sequenced writer's wave (a process's filesystem client): the number of
|
|
104
|
+
* its first op, each op after it the next, and the highest cursor the
|
|
105
|
+
* writer has had answered. The session keeps the writer's cursor with its
|
|
106
|
+
* commits, so an op a re-send carries again is answered, not applied twice.
|
|
107
|
+
*/
|
|
108
|
+
seq?: number;
|
|
109
|
+
ack?: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The supervisor surface a writer publishes through. */
|
|
113
|
+
export interface WaveSupervisor {
|
|
114
|
+
/**
|
|
115
|
+
* `fence` is absent when the supervisor issued no epoch: the session
|
|
116
|
+
* fences nothing. `owner`: the delegation the wave is written under, when
|
|
117
|
+
* it is not the one the binding was made with.
|
|
118
|
+
*/
|
|
119
|
+
writeBatchStream(stream: ReadableStream<Uint8Array>, fence?: WaveFence, owner?: string): Promise<unknown>;
|
|
120
|
+
/**
|
|
121
|
+
* A writer epoch from the session, the only identity it admits fenced
|
|
122
|
+
* waves under, or null when it fences nothing. Absent on a supervisor
|
|
123
|
+
* that is the session itself (nothing between them can lose a call).
|
|
124
|
+
*/
|
|
125
|
+
openWaveWriter?(): Promise<string | null>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A published file as the session will stat it: what a warm index entry needs. */
|
|
129
|
+
export interface WaveFileReceipt {
|
|
130
|
+
path: string;
|
|
131
|
+
ino: number;
|
|
132
|
+
mode: number;
|
|
133
|
+
size: number;
|
|
134
|
+
mtimeMs: number;
|
|
135
|
+
ctimeMs: number;
|
|
136
|
+
uid: number;
|
|
137
|
+
gid: number;
|
|
138
|
+
dev: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface WaveReport {
|
|
142
|
+
wave: number;
|
|
143
|
+
files: number;
|
|
144
|
+
bytes: number;
|
|
145
|
+
rpcWallMs: number;
|
|
146
|
+
receipts: WaveFileReceipt[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** What one wave carried, for the caller's own view of the paths it published. */
|
|
150
|
+
export interface WaveCut<Meta> {
|
|
151
|
+
wave: number;
|
|
152
|
+
mtimeMs: number;
|
|
153
|
+
/** Each file and link, with what its caller attached to the record. */
|
|
154
|
+
files: { path: string; meta: Meta | undefined }[];
|
|
155
|
+
directories: string[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface WaveWriterOptions<Meta = undefined> {
|
|
159
|
+
supervisor: WaveSupervisor;
|
|
160
|
+
/**
|
|
161
|
+
* The directory a wave publishes up to, inclusive: a record's directories
|
|
162
|
+
* above it are not the writer's. Null publishes every ancestor.
|
|
163
|
+
*/
|
|
164
|
+
root: string | null;
|
|
165
|
+
/** An existing worktree: only directories strictly below it are published. */
|
|
166
|
+
worktreeRoot?: string | null;
|
|
167
|
+
/** Record paths are relative to it (joined with '/'); omitted, they are VFS paths. */
|
|
168
|
+
base?: string;
|
|
169
|
+
/** Wall-clock time (ms) after which no new wave starts. */
|
|
170
|
+
deadline?: number | null;
|
|
171
|
+
/** Every inode's mtime; omitted, each wave stamps the time it was cut. */
|
|
172
|
+
mtimeMs?: number;
|
|
173
|
+
/** A directory's mode, as the caller knows it; omitted or undefined, 0o755. */
|
|
174
|
+
directoryMode?: (path: string) => number | undefined;
|
|
175
|
+
/** Called synchronously as a wave is cut, before it is sent. */
|
|
176
|
+
onCut?: (cut: WaveCut<Meta>) => void;
|
|
177
|
+
/** Called once per published wave, in order. */
|
|
178
|
+
onWave?: (report: WaveReport) => void;
|
|
179
|
+
/** A record's `meta` names its owner, and a failed wave fails only the owners it carried. */
|
|
180
|
+
failPerOwner?: boolean;
|
|
181
|
+
/** The lost-call policy's timings (lost-call.ts); tests shorten them. */
|
|
182
|
+
retry?: { backoffMs: readonly number[]; stallMs: number; answerDeadlineMs: number };
|
|
183
|
+
/** Called before each re-send of a lost wave, with its lost-call attributes (lost-call.ts). */
|
|
184
|
+
onResend?: (lost: Record<string, string | number>) => void;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface WaveStats {
|
|
188
|
+
waves: number;
|
|
189
|
+
files: number;
|
|
190
|
+
bytes: number;
|
|
191
|
+
rpcWallMs: number;
|
|
192
|
+
maxRpcWallMs: number;
|
|
193
|
+
producerWaitMs: number;
|
|
194
|
+
/** Paths probed by ownership accounting: linear in records, never wave × records. */
|
|
195
|
+
ownershipVisits: number;
|
|
196
|
+
maxWavePaths: number;
|
|
197
|
+
maxWaveBytes: number;
|
|
198
|
+
/** Waves sent again after their transport was lost. */
|
|
199
|
+
retries: number;
|
|
200
|
+
/** Waves encoded whole before they were sent (the rest streamed as encoded). */
|
|
201
|
+
wholeWaves: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
type BufferedRecord<Meta> =
|
|
205
|
+
| { kind: 'file' | 'symlink'; mode: number; bytes: Uint8Array; meta: Meta | undefined }
|
|
206
|
+
| { kind: 'stream'; mode: number; size: number; source: AsyncIterable<Uint8Array>; meta: Meta | undefined };
|
|
207
|
+
|
|
208
|
+
interface Pin {
|
|
209
|
+
path: string;
|
|
210
|
+
bytes: Uint8Array;
|
|
211
|
+
durable: boolean;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
interface Tally {
|
|
215
|
+
pathCount: number;
|
|
216
|
+
pathBytes: number;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export class WaveFailure extends Error {
|
|
220
|
+
readonly wave: number;
|
|
221
|
+
constructor(wave: number, cause: unknown) {
|
|
222
|
+
super(`write wave ${wave} failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
223
|
+
this.name = 'WaveFailure';
|
|
224
|
+
this.wave = wave;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const encoder = new TextEncoder();
|
|
229
|
+
|
|
230
|
+
function parentOf(path: string): string {
|
|
231
|
+
const slash = path.lastIndexOf('/');
|
|
232
|
+
return slash < 0 ? '' : path.slice(0, slash);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
|
|
236
|
+
return typeof value === 'object' && value !== null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function waveResultError(result: unknown): Error | null {
|
|
240
|
+
if (!isRecord(result)) return new Error('writeBatchStream failed: missing writeBatchStream result');
|
|
241
|
+
if (result.ok === true) return null;
|
|
242
|
+
const error = result.error;
|
|
243
|
+
const detail = isRecord(error) && typeof error.message === 'string'
|
|
244
|
+
? error.message
|
|
245
|
+
: 'missing writeBatchStream result';
|
|
246
|
+
return new Error(
|
|
247
|
+
'writeBatchStream failed after group ' + String(result.committedGroupSequence) +
|
|
248
|
+
' (' + String(result.committedPathCount) + ' committed paths): ' + detail,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const RECEIPT_NUMBERS = ['ino', 'mode', 'size', 'mtimeMs', 'ctimeMs', 'uid', 'gid', 'dev'] as const;
|
|
253
|
+
|
|
254
|
+
/** The session's stat of each published file, as it answered the wave. */
|
|
255
|
+
function parseReceipts(result: unknown): WaveFileReceipt[] {
|
|
256
|
+
if (!isRecord(result) || result.receipts === undefined) return [];
|
|
257
|
+
if (!Array.isArray(result.receipts)) throw new Error('writeBatchStream receipts are not a list');
|
|
258
|
+
return result.receipts.map((value: unknown) => {
|
|
259
|
+
if (!isRecord(value) || typeof value.path !== 'string') {
|
|
260
|
+
throw new Error('writeBatchStream receipt is invalid');
|
|
261
|
+
}
|
|
262
|
+
const receipt: WaveFileReceipt = {
|
|
263
|
+
path: value.path, ino: 0, mode: 0, size: 0, mtimeMs: 0, ctimeMs: 0, uid: 0, gid: 0, dev: 0,
|
|
264
|
+
};
|
|
265
|
+
for (const field of RECEIPT_NUMBERS) {
|
|
266
|
+
const number = value[field];
|
|
267
|
+
if (typeof number !== 'number' || !Number.isFinite(number)) {
|
|
268
|
+
throw new Error(`writeBatchStream receipt for ${value.path} has no ${field}`);
|
|
269
|
+
}
|
|
270
|
+
receipt[field] = number;
|
|
271
|
+
}
|
|
272
|
+
return receipt;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* A view of `bytes` that owns its buffer. The writer holds a record until
|
|
278
|
+
* its wave publishes, and may send it again: a view would pin its whole
|
|
279
|
+
* parent buffer for that long, and see whatever the caller wrote to it next.
|
|
280
|
+
*/
|
|
281
|
+
function ownedBytes(bytes: Uint8Array): Uint8Array {
|
|
282
|
+
if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) return bytes;
|
|
283
|
+
// A copy: a Node Buffer's slice() is another view of the same memory.
|
|
284
|
+
return new Uint8Array(bytes);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export class WaveWriter<Meta = undefined> {
|
|
288
|
+
private readonly options: WaveWriterOptions<Meta>;
|
|
289
|
+
private readonly records = new Map<string, BufferedRecord<Meta>>();
|
|
290
|
+
private readonly directories = new Set<string>();
|
|
291
|
+
private readonly deletes = new Set<string>();
|
|
292
|
+
private bufferedBytes = 0;
|
|
293
|
+
private symlinks = 0;
|
|
294
|
+
/** Links in the wave in flight: sent, not yet published. */
|
|
295
|
+
private inFlightSymlinks = 0;
|
|
296
|
+
/** Every path the buffered wave publishes; a superset once a buffered record is removed. */
|
|
297
|
+
private readonly owned = new Set<string>();
|
|
298
|
+
/** The upward-closed part of `owned`: each one's chain, to the root, is owned too. */
|
|
299
|
+
private readonly ownedDirectories = new Set<string>();
|
|
300
|
+
private ownedPathBytes = 0;
|
|
301
|
+
private pin: Pin | null = null;
|
|
302
|
+
private inFlight: Promise<void> | null = null;
|
|
303
|
+
private cutQueue: Promise<unknown> = Promise.resolve();
|
|
304
|
+
private sequence = 0;
|
|
305
|
+
private failure: WaveFailure | null = null;
|
|
306
|
+
/** Owners (records' `meta`) whose records a failed wave carried. */
|
|
307
|
+
private readonly failedOwners = new Map<Meta, WaveFailure>();
|
|
308
|
+
/** With `failPerOwner`: owners with a write in the buffered wave, superseded ones included. */
|
|
309
|
+
private readonly bufferedOwners = new Set<Meta>();
|
|
310
|
+
/** With `failPerOwner`: the last wave that carried each owner's writes. */
|
|
311
|
+
private readonly ownerWaves = new Map<Meta, number>();
|
|
312
|
+
/** Waves publish in order: every wave up to this one has settled, this one published. */
|
|
313
|
+
private lastPublishedWave = 0;
|
|
314
|
+
private readonly counters: WaveStats = {
|
|
315
|
+
waves: 0,
|
|
316
|
+
files: 0,
|
|
317
|
+
bytes: 0,
|
|
318
|
+
rpcWallMs: 0,
|
|
319
|
+
maxRpcWallMs: 0,
|
|
320
|
+
producerWaitMs: 0,
|
|
321
|
+
ownershipVisits: 0,
|
|
322
|
+
maxWavePaths: 0,
|
|
323
|
+
maxWaveBytes: 0,
|
|
324
|
+
retries: 0,
|
|
325
|
+
wholeWaves: 0,
|
|
326
|
+
};
|
|
327
|
+
/** The session's epoch for this writer, and when it was opened (null: unfenced). */
|
|
328
|
+
private epoch: Promise<{ writer: string | null; openedAt: number }> | null = null;
|
|
329
|
+
/** Mutations run one at a time, in call order: concurrent writers interleave by record. */
|
|
330
|
+
private mutations: Promise<unknown> = Promise.resolve();
|
|
331
|
+
|
|
332
|
+
constructor(options: WaveWriterOptions<Meta>) {
|
|
333
|
+
this.options = options;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── Records ──────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
/** Run `mutate` once every mutation called before it has finished. */
|
|
339
|
+
private exclusive<T>(mutate: () => Promise<T>): Promise<T> {
|
|
340
|
+
const run = this.mutations.then(mutate);
|
|
341
|
+
this.mutations = run.catch(() => {});
|
|
342
|
+
return run;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* A regular file, with its permission bits as given (`mode & 0o7777`). The
|
|
347
|
+
* writer takes `bytes`; a view sharing its buffer is copied. `meta` rides
|
|
348
|
+
* with the record, back to the caller as it is cut.
|
|
349
|
+
*/
|
|
350
|
+
file(path: string, mode: number, bytes: Uint8Array, meta?: Meta): Promise<void> {
|
|
351
|
+
return this.exclusive(async () => {
|
|
352
|
+
const key = this.key(path);
|
|
353
|
+
this.assertOwnerHealthy(meta);
|
|
354
|
+
await this.afterRemoval(key);
|
|
355
|
+
await this.admit(key, bytes.byteLength, true);
|
|
356
|
+
this.buffer(key, { kind: 'file', mode: mode & 0o7777, bytes: ownedBytes(bytes), meta });
|
|
357
|
+
await this.cutIfFull();
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A symbolic link to `target`. */
|
|
362
|
+
symlink(path: string, target: string, meta?: Meta): Promise<void> {
|
|
363
|
+
return this.exclusive(async () => {
|
|
364
|
+
const key = this.key(path);
|
|
365
|
+
const bytes = encoder.encode(target);
|
|
366
|
+
this.assertOwnerHealthy(meta);
|
|
367
|
+
await this.afterRemoval(key);
|
|
368
|
+
await this.admit(key, bytes.byteLength, true);
|
|
369
|
+
this.buffer(key, { kind: 'symlink', mode: 0o777, bytes, meta });
|
|
370
|
+
await this.cutIfFull();
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* A regular file of `size` bytes read from `chunks` as its wave drains:
|
|
376
|
+
* the buffered wave is sent first, then this file travels alone, and the
|
|
377
|
+
* call resolves once its source is consumed.
|
|
378
|
+
*/
|
|
379
|
+
fileChunks(
|
|
380
|
+
path: string,
|
|
381
|
+
mode: number,
|
|
382
|
+
size: number,
|
|
383
|
+
chunks: AsyncIterable<Uint8Array>,
|
|
384
|
+
meta?: Meta,
|
|
385
|
+
): Promise<void> {
|
|
386
|
+
return this.exclusive(async () => {
|
|
387
|
+
if (!Number.isSafeInteger(size) || size < 0) throw new Error(`write wave: ${path}: invalid size ${size}`);
|
|
388
|
+
const key = this.key(path);
|
|
389
|
+
this.assertOwnerHealthy(meta);
|
|
390
|
+
if (this.hasBuffered()) await this.cut();
|
|
391
|
+
this.buffer(key, { kind: 'stream', mode: mode & 0o7777, size, source: chunks, meta });
|
|
392
|
+
await this.cut();
|
|
393
|
+
if (this.inFlight) await this.inFlight;
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** A directory (an empty one, a gitlink): files' directories need no record. */
|
|
398
|
+
directory(path: string): Promise<void> {
|
|
399
|
+
return this.exclusive(async () => {
|
|
400
|
+
const key = this.key(path);
|
|
401
|
+
await this.afterRemoval(key);
|
|
402
|
+
await this.admit(key, 0, true);
|
|
403
|
+
this.directories.add(key);
|
|
404
|
+
this.deletes.delete(key);
|
|
405
|
+
const tally = this.tally();
|
|
406
|
+
this.ownPath(key, true, tally);
|
|
407
|
+
this.walkChain(key, true, tally);
|
|
408
|
+
this.ownedPathBytes = tally.pathBytes;
|
|
409
|
+
await this.cutIfFull();
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Remove what stands at `path`, its subtree included. A buffered record at
|
|
415
|
+
* the path is dropped; with `directory`, a buffered mkdir of it too.
|
|
416
|
+
*/
|
|
417
|
+
remove(path: string, directory = false): Promise<void> {
|
|
418
|
+
return this.exclusive(async () => {
|
|
419
|
+
const key = this.key(path);
|
|
420
|
+
await this.admit(key, 0, false);
|
|
421
|
+
this.drop(key);
|
|
422
|
+
if (directory) this.directories.delete(key);
|
|
423
|
+
this.deletes.add(key);
|
|
424
|
+
const tally = this.tally();
|
|
425
|
+
this.ownPath(key, true, tally);
|
|
426
|
+
this.ownedPathBytes = tally.pathBytes;
|
|
427
|
+
await this.cutIfFull();
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* A file every wave re-asserts until a wave carrying it publishes (a
|
|
433
|
+
* clone's ownership marker): parent directories publish independently of
|
|
434
|
+
* files, so each wave leaves the marker's proof in place. `durable` says
|
|
435
|
+
* these bytes are already published.
|
|
436
|
+
*/
|
|
437
|
+
setPin(path: string, text: string, durable = false): void {
|
|
438
|
+
this.pin = { path: this.key(path), bytes: encoder.encode(text), durable };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
clearPin(path: string): void {
|
|
442
|
+
if (this.pin && this.pin.path === this.key(path)) this.pin = null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── The buffered wave, for a reader that must see it ─────────────────
|
|
446
|
+
|
|
447
|
+
/** The bytes buffered at `path`, if a file or link is. */
|
|
448
|
+
buffered(path: string): Uint8Array | undefined {
|
|
449
|
+
const record = this.records.get(path);
|
|
450
|
+
return record && record.kind !== 'stream' ? record.bytes : undefined;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The buffered file or link at `path`: its kind, size and the caller's `meta`. */
|
|
454
|
+
bufferedRecord(path: string): { kind: 'file' | 'symlink'; size: number; meta: Meta | undefined } | undefined {
|
|
455
|
+
const record = this.records.get(path);
|
|
456
|
+
if (record === undefined) return undefined;
|
|
457
|
+
return record.kind === 'stream'
|
|
458
|
+
? { kind: 'file', size: record.size, meta: record.meta }
|
|
459
|
+
: { kind: record.kind, size: record.bytes.byteLength, meta: record.meta };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
isBufferedDirectory(path: string): boolean {
|
|
463
|
+
return this.directories.has(path);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
isBufferedDelete(path: string): boolean {
|
|
467
|
+
return this.deletes.has(path);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
bufferedPaths(): { files: Iterable<string>; directories: Iterable<string>; deletes: Iterable<string> } {
|
|
471
|
+
return { files: this.records.keys(), directories: this.directories, deletes: this.deletes };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Whether a link is buffered or in flight: written, not yet published. */
|
|
475
|
+
get hasUnpublishedSymlinks(): boolean {
|
|
476
|
+
return this.symlinks > 0 || this.inFlightSymlinks > 0;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── Publication ──────────────────────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
/** Every record written before this call is durable; rejects with the first failed wave. */
|
|
482
|
+
flush(): Promise<void> {
|
|
483
|
+
return this.exclusive(async () => {
|
|
484
|
+
await this.cut();
|
|
485
|
+
if (this.inFlight) await this.inFlight;
|
|
486
|
+
this.assertHealthy();
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The wave in flight has settled; nothing new is cut. */
|
|
491
|
+
async settled(): Promise<void> {
|
|
492
|
+
if (this.inFlight) await this.inFlight.catch(() => {});
|
|
493
|
+
this.assertHealthy();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
assertHealthy(): void {
|
|
497
|
+
if (this.failure) throw this.failure;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** The failure of the wave that carried `owner`'s records, if one failed. */
|
|
501
|
+
failureOf(owner: Meta): WaveFailure | undefined {
|
|
502
|
+
return this.failedOwners.get(owner);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Whether every record written with `owner` (as `meta`, under
|
|
507
|
+
* `failPerOwner`) is durable: each wave that carried it, or a write
|
|
508
|
+
* superseding it, published.
|
|
509
|
+
*/
|
|
510
|
+
published(owner: Meta): boolean {
|
|
511
|
+
if (this.options.failPerOwner !== true) throw new Error('published(owner) needs failPerOwner');
|
|
512
|
+
return !this.failedOwners.has(owner)
|
|
513
|
+
&& !this.bufferedOwners.has(owner)
|
|
514
|
+
&& (this.ownerWaves.get(owner) ?? 0) <= this.lastPublishedWave;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private assertOwnerHealthy(owner: Meta | undefined): void {
|
|
518
|
+
this.assertHealthy();
|
|
519
|
+
if (owner === undefined) return;
|
|
520
|
+
const failure = this.failedOwners.get(owner);
|
|
521
|
+
if (failure) throw failure;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
get failed(): WaveFailure | null {
|
|
525
|
+
return this.failure;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
stats(): WaveStats {
|
|
529
|
+
return { ...this.counters };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── Internals ────────────────────────────────────────────────────────
|
|
533
|
+
|
|
534
|
+
private key(path: string): string {
|
|
535
|
+
return this.options.base ? this.options.base + '/' + path : path;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private tally(): Tally {
|
|
539
|
+
return { pathCount: this.owned.size, pathBytes: this.ownedPathBytes };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private hasBuffered(): boolean {
|
|
543
|
+
return this.records.size > 0 || this.directories.size > 0 || this.deletes.size > 0;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private ownPath(path: string, admit: boolean, tally: Tally): void {
|
|
547
|
+
this.counters.ownershipVisits++;
|
|
548
|
+
if (this.owned.has(path)) return;
|
|
549
|
+
tally.pathCount++;
|
|
550
|
+
tally.pathBytes += utf8Length(path);
|
|
551
|
+
if (admit) this.owned.add(path);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** collectDirectoryPaths' chain from `path` upward, to the first directory already owned. */
|
|
555
|
+
private walkChain(path: string, admit: boolean, tally: Tally): void {
|
|
556
|
+
const { root, worktreeRoot = null } = this.options;
|
|
557
|
+
let current = path;
|
|
558
|
+
while (current) {
|
|
559
|
+
if (root && current !== root && !current.startsWith(root + '/')) break;
|
|
560
|
+
if (worktreeRoot !== null && !current.startsWith(worktreeRoot + '/')) break;
|
|
561
|
+
this.counters.ownershipVisits++;
|
|
562
|
+
if (this.ownedDirectories.has(current)) break;
|
|
563
|
+
if (admit) this.ownedDirectories.add(current);
|
|
564
|
+
if (!this.owned.has(current)) {
|
|
565
|
+
tally.pathCount++;
|
|
566
|
+
tally.pathBytes += utf8Length(current);
|
|
567
|
+
if (admit) this.owned.add(current);
|
|
568
|
+
}
|
|
569
|
+
if (current === root) break;
|
|
570
|
+
current = parentOf(current);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Send what is buffered when a removal of `path` is pending in it: a wave
|
|
576
|
+
* names a path once, and what is made there must land after the removal,
|
|
577
|
+
* as rm then create does (the directory's permission decides, not the old
|
|
578
|
+
* file's mode).
|
|
579
|
+
*/
|
|
580
|
+
private async afterRemoval(path: string): Promise<void> {
|
|
581
|
+
if (this.deletes.has(path)) await this.cut();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Cut waves until `path` (with its chain) and `bytes` fit beside what is buffered. */
|
|
585
|
+
private async admit(path: string, bytes: number, withParents: boolean): Promise<void> {
|
|
586
|
+
this.assertHealthy();
|
|
587
|
+
while (this.hasBuffered()) {
|
|
588
|
+
const tally = this.tally();
|
|
589
|
+
this.ownPath(path, false, tally);
|
|
590
|
+
if (withParents) this.walkChain(parentOf(path), false, tally);
|
|
591
|
+
const replaced = this.records.get(path);
|
|
592
|
+
const replacedBytes = replaced && replaced.kind !== 'stream' ? replaced.bytes.byteLength : 0;
|
|
593
|
+
if (this.bufferedBytes - replacedBytes + bytes <= WAVE_BYTES &&
|
|
594
|
+
tally.pathCount < WAVE_PATHS &&
|
|
595
|
+
tally.pathBytes < WAVE_PATH_BYTES) return;
|
|
596
|
+
await this.cut();
|
|
597
|
+
}
|
|
598
|
+
const tally = this.tally();
|
|
599
|
+
this.ownPath(path, false, tally);
|
|
600
|
+
if (withParents) this.walkChain(parentOf(path), false, tally);
|
|
601
|
+
if (tally.pathCount > W7_MAX_PATHS_PER_BATCH) {
|
|
602
|
+
throw new Error('write wave exceeds ' + W7_MAX_PATHS_PER_BATCH + ' owned paths');
|
|
603
|
+
}
|
|
604
|
+
if (tally.pathBytes > W7_MAX_OWNED_PATH_BYTES) {
|
|
605
|
+
throw new Error('write wave exceeds ' + W7_MAX_OWNED_PATH_BYTES + ' owned path bytes');
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private buffer(path: string, record: BufferedRecord<Meta>): void {
|
|
610
|
+
this.drop(path);
|
|
611
|
+
if (this.options.failPerOwner === true && record.meta !== undefined) this.bufferedOwners.add(record.meta);
|
|
612
|
+
this.deletes.delete(path);
|
|
613
|
+
this.records.set(path, record);
|
|
614
|
+
if (record.kind === 'symlink') this.symlinks++;
|
|
615
|
+
if (record.kind !== 'stream') this.bufferedBytes += record.bytes.byteLength;
|
|
616
|
+
const tally = this.tally();
|
|
617
|
+
this.ownPath(path, true, tally);
|
|
618
|
+
this.walkChain(parentOf(path), true, tally);
|
|
619
|
+
this.ownedPathBytes = tally.pathBytes;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private drop(path: string): void {
|
|
623
|
+
const previous = this.records.get(path);
|
|
624
|
+
if (!previous) return;
|
|
625
|
+
if (previous.kind === 'symlink') this.symlinks--;
|
|
626
|
+
if (previous.kind !== 'stream') this.bufferedBytes -= previous.bytes.byteLength;
|
|
627
|
+
this.records.delete(path);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private async cutIfFull(): Promise<void> {
|
|
631
|
+
if (this.owned.size >= WAVE_PATHS ||
|
|
632
|
+
this.ownedPathBytes >= WAVE_PATH_BYTES ||
|
|
633
|
+
this.bufferedBytes >= WAVE_BYTES) {
|
|
634
|
+
await this.cut();
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Send the buffered wave once the one in flight has published. Cuts are
|
|
640
|
+
* serialised, so at most one wave is in flight and one buffers.
|
|
641
|
+
*/
|
|
642
|
+
private cut(): Promise<void> {
|
|
643
|
+
const run = this.cutQueue.then(() => this.cutNow());
|
|
644
|
+
this.cutQueue = run.catch(() => {});
|
|
645
|
+
return run;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private async cutNow(): Promise<void> {
|
|
649
|
+
if (this.inFlight) {
|
|
650
|
+
const waitStarted = Date.now();
|
|
651
|
+
await this.inFlight.catch(() => {});
|
|
652
|
+
this.counters.producerWaitMs += Date.now() - waitStarted;
|
|
653
|
+
}
|
|
654
|
+
this.assertHealthy();
|
|
655
|
+
// A wave that failed took its owners with it: their records still
|
|
656
|
+
// buffered are not sent (an owner's later record must not publish
|
|
657
|
+
// without its earlier ones).
|
|
658
|
+
if (this.failedOwners.size > 0) {
|
|
659
|
+
for (const [path, record] of [...this.records]) {
|
|
660
|
+
if (record.meta !== undefined && this.failedOwners.has(record.meta)) this.drop(path);
|
|
661
|
+
}
|
|
662
|
+
for (const owner of [...this.bufferedOwners]) if (this.failedOwners.has(owner)) this.bufferedOwners.delete(owner);
|
|
663
|
+
}
|
|
664
|
+
this.bufferPin();
|
|
665
|
+
if (!this.hasBuffered()) return;
|
|
666
|
+
// A wave already sent can still publish after the deadline; none starts after it.
|
|
667
|
+
const { deadline = null } = this.options;
|
|
668
|
+
if (deadline !== null && Date.now() >= deadline) {
|
|
669
|
+
throw new Error('phase deadline reached before starting a new write wave');
|
|
670
|
+
}
|
|
671
|
+
const wave = ++this.sequence;
|
|
672
|
+
const sentPin = this.pin;
|
|
673
|
+
const owners = [...this.bufferedOwners];
|
|
674
|
+
for (const owner of owners) this.ownerWaves.set(owner, wave);
|
|
675
|
+
const mtime = this.options.mtimeMs ?? Date.now();
|
|
676
|
+
const files: { path: string; meta: Meta | undefined }[] = [];
|
|
677
|
+
const directories = this.publishedDirectories();
|
|
678
|
+
const inodes: BatchInodeEntry[] = [];
|
|
679
|
+
const chunks: BatchChunkEntry[] = [];
|
|
680
|
+
const streams: BatchStreamEntry[] = [];
|
|
681
|
+
let waveBytes = 0;
|
|
682
|
+
for (const dir of directories) {
|
|
683
|
+
inodes.push({
|
|
684
|
+
path: dir, parentPath: parentOf(dir), kind: 'directory', isDir: true,
|
|
685
|
+
size: 0, mtime, mode: this.options.directoryMode?.(dir) ?? 0o755, chunkCount: 0,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
for (const [path, record] of this.records) {
|
|
689
|
+
files.push({ path, meta: record.meta });
|
|
690
|
+
const size = record.kind === 'stream' ? record.size : record.bytes.byteLength;
|
|
691
|
+
const chunkCount = w7ChunkCount(size);
|
|
692
|
+
waveBytes += size;
|
|
693
|
+
inodes.push({
|
|
694
|
+
path, parentPath: parentOf(path),
|
|
695
|
+
kind: record.kind === 'symlink' ? 'symlink' : 'file',
|
|
696
|
+
isDir: false, size, mtime, mode: record.mode, chunkCount,
|
|
697
|
+
});
|
|
698
|
+
if (record.kind === 'stream') {
|
|
699
|
+
streams.push({ path, source: record.source });
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
// Views: the encoder copies a chunk's bytes into the buffers it
|
|
703
|
+
// enqueues, so the record's bytes stay whole for a re-send.
|
|
704
|
+
chunks.push(...w7Chunks(path, record.bytes));
|
|
705
|
+
}
|
|
706
|
+
const deletePaths = this.deletes.size > 0 ? [...this.deletes] : undefined;
|
|
707
|
+
const ownedOnly = this.options.failPerOwner === true
|
|
708
|
+
&& files.length > 0
|
|
709
|
+
&& files.every((file) => file.meta !== undefined)
|
|
710
|
+
&& this.directories.size === 0
|
|
711
|
+
&& this.deletes.size === 0
|
|
712
|
+
&& (sentPin === null || !this.records.has(sentPin.path));
|
|
713
|
+
const paths = this.owned.size;
|
|
714
|
+
const symlinks = this.symlinks;
|
|
715
|
+
this.options.onCut?.({ wave, mtimeMs: mtime, files, directories });
|
|
716
|
+
// The payload holds the wave's bytes now; the buffer lets go of them so
|
|
717
|
+
// the facet holds one copy while the stream drains.
|
|
718
|
+
this.records.clear();
|
|
719
|
+
this.bufferedOwners.clear();
|
|
720
|
+
this.directories.clear();
|
|
721
|
+
this.deletes.clear();
|
|
722
|
+
this.owned.clear();
|
|
723
|
+
this.ownedDirectories.clear();
|
|
724
|
+
this.ownedPathBytes = 0;
|
|
725
|
+
this.bufferedBytes = 0;
|
|
726
|
+
this.symlinks = 0;
|
|
727
|
+
this.counters.maxWavePaths = Math.max(this.counters.maxWavePaths, paths);
|
|
728
|
+
this.counters.maxWaveBytes = Math.max(this.counters.maxWaveBytes, waveBytes);
|
|
729
|
+
|
|
730
|
+
this.inFlightSymlinks = symlinks;
|
|
731
|
+
const sentAt = Date.now();
|
|
732
|
+
const opened = this.open({ inodes, chunks, deletePaths, streams }, waveBytes);
|
|
733
|
+
const published = opened.then(({ open, streamed }) => this.sendAttempts(open, streamed, wave)).then((result) => {
|
|
734
|
+
try {
|
|
735
|
+
const error = waveResultError(result);
|
|
736
|
+
if (error) throw error;
|
|
737
|
+
const rpcWallMs = Date.now() - sentAt;
|
|
738
|
+
if (sentPin !== null && this.pin === sentPin) sentPin.durable = true;
|
|
739
|
+
this.inFlightSymlinks = 0;
|
|
740
|
+
this.lastPublishedWave = wave;
|
|
741
|
+
this.counters.waves++;
|
|
742
|
+
this.counters.files += files.length;
|
|
743
|
+
this.counters.bytes += waveBytes;
|
|
744
|
+
this.counters.rpcWallMs += rpcWallMs;
|
|
745
|
+
this.counters.maxRpcWallMs = Math.max(this.counters.maxRpcWallMs, rpcWallMs);
|
|
746
|
+
this.options.onWave?.({ wave, files: files.length, bytes: waveBytes, rpcWallMs, receipts: parseReceipts(result) });
|
|
747
|
+
} finally {
|
|
748
|
+
disposeRpcResource(result);
|
|
749
|
+
}
|
|
750
|
+
}).catch((error: unknown) => {
|
|
751
|
+
const failure = error instanceof WaveFailure ? error : new WaveFailure(wave, error);
|
|
752
|
+
this.inFlightSymlinks = 0;
|
|
753
|
+
// A wave whose every record has an owner fails those owners, and
|
|
754
|
+
// the owners of writes its records superseded; the writer goes on
|
|
755
|
+
// for the rest. Anything unowned in it (a directory, a removal, the
|
|
756
|
+
// pin, a record without meta) fails the writer.
|
|
757
|
+
if (ownedOnly) {
|
|
758
|
+
for (const owner of owners) this.failedOwners.set(owner, failure);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (this.failure === null) this.failure = failure;
|
|
762
|
+
throw this.failure;
|
|
763
|
+
}).finally(() => {
|
|
764
|
+
if (this.inFlight === published) this.inFlight = null;
|
|
765
|
+
});
|
|
766
|
+
// Its failure reaches whoever waits next; it is never unobserved. In
|
|
767
|
+
// flight from here, before its encode is awaited: its records are no
|
|
768
|
+
// longer buffered, so settled() must wait for it.
|
|
769
|
+
published.catch(() => {});
|
|
770
|
+
this.inFlight = published;
|
|
771
|
+
// The next wave buffers only once this one is encoded and its records
|
|
772
|
+
// let go, so a producer holds one wave's bytes beside the next, not two;
|
|
773
|
+
// its answer is still awaited only by the cut after it.
|
|
774
|
+
await opened.catch(() => {});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* How a wave of `bytes` content crosses: an attempt's stream, made anew
|
|
779
|
+
* for each attempt.
|
|
780
|
+
*
|
|
781
|
+
* Across a transport (a supervisor that opens epochs), a wave held in
|
|
782
|
+
* memory is encoded whole before it is sent, and the transport takes it in
|
|
783
|
+
* SEND_SLICE_BYTES pieces, never waiting on the encoder. A re-send sends
|
|
784
|
+
* the same bytes, and the wave's records are let go once it is encoded.
|
|
785
|
+
* Measured, eight producers into one session, Markflow's file sizes: the
|
|
786
|
+
* encoder's stream, pulled through the watchdog as the transport read it,
|
|
787
|
+
* took 1,002 files/s; the wave encoded first, 1,374 (re-chunking that
|
|
788
|
+
* stream to 64 KiB or 1 MiB gained nothing). A wave with a streamed
|
|
789
|
+
* source is never held whole, nor is a lone file larger than a wave, and a
|
|
790
|
+
* session's own writer has no transport to wait on: they stream as the
|
|
791
|
+
* encoder makes them.
|
|
792
|
+
*/
|
|
793
|
+
private async open(payload: BatchWritePayload, bytes: number): Promise<{ open: () => ReadableStream<Uint8Array>; streamed: boolean }> {
|
|
794
|
+
const streamed = (payload.streams?.length ?? 0) > 0;
|
|
795
|
+
// A file larger than a wave travels in a wave of its own: never copied whole.
|
|
796
|
+
if (streamed || bytes > WAVE_BYTES || this.options.supervisor.openWaveWriter === undefined) {
|
|
797
|
+
return { open: encoderOf(payload), streamed };
|
|
798
|
+
}
|
|
799
|
+
const whole = await encodeWriteBatch(payload);
|
|
800
|
+
this.counters.wholeWaves++;
|
|
801
|
+
return { open: piecesOf(whole, SEND_SLICE_BYTES), streamed: false };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Send one wave (sendWaveAttempts) under this writer's epoch. */
|
|
805
|
+
private sendAttempts(open: () => ReadableStream<Uint8Array>, streamed: boolean, wave: number): Promise<unknown> {
|
|
806
|
+
return sendWaveAttempts({
|
|
807
|
+
supervisor: this.options.supervisor,
|
|
808
|
+
writer: () => this.currentEpoch(),
|
|
809
|
+
open,
|
|
810
|
+
streamed,
|
|
811
|
+
wave,
|
|
812
|
+
...(this.options.retry === undefined ? {} : { retry: this.options.retry }),
|
|
813
|
+
resent: (lost) => {
|
|
814
|
+
this.counters.retries++;
|
|
815
|
+
this.options.onResend?.(lost);
|
|
816
|
+
},
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* The epoch this writer's waves are fenced under: opened before its first
|
|
822
|
+
* wave, and again once half of WAVE_EPOCH_TTL_MS has passed, so a wave is
|
|
823
|
+
* never sent under an epoch about to close.
|
|
824
|
+
*/
|
|
825
|
+
private async currentEpoch(): Promise<string | null> {
|
|
826
|
+
const supervisor = this.options.supervisor;
|
|
827
|
+
if (supervisor.openWaveWriter === undefined) return null;
|
|
828
|
+
const now = Date.now();
|
|
829
|
+
const held = this.epoch === null ? null : await this.epoch;
|
|
830
|
+
if (held === null || (held.writer !== null && now - held.openedAt >= WAVE_EPOCH_TTL_MS / 2)) {
|
|
831
|
+
// Called as a method of the supervisor, never through .call/.apply:
|
|
832
|
+
// on an RPC stub those are remote method names too.
|
|
833
|
+
this.epoch = supervisor.openWaveWriter().then((writer) => ({ writer, openedAt: now }));
|
|
834
|
+
}
|
|
835
|
+
return (await this.epoch!).writer;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** The directories the buffered records publish, shallowest first. */
|
|
839
|
+
private publishedDirectories(): string[] {
|
|
840
|
+
const dirs = new Set<string>();
|
|
841
|
+
const { root, worktreeRoot = null } = this.options;
|
|
842
|
+
const collect = (path: string): void => {
|
|
843
|
+
let current = path;
|
|
844
|
+
while (current) {
|
|
845
|
+
if (root && current !== root && !current.startsWith(root + '/')) break;
|
|
846
|
+
if (worktreeRoot !== null && !current.startsWith(worktreeRoot + '/')) break;
|
|
847
|
+
if (dirs.has(current)) break;
|
|
848
|
+
dirs.add(current);
|
|
849
|
+
if (current === root) break;
|
|
850
|
+
current = parentOf(current);
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
for (const path of this.records.keys()) collect(parentOf(path));
|
|
854
|
+
for (const dir of this.directories) collect(dir);
|
|
855
|
+
return [...dirs].sort((left, right) => {
|
|
856
|
+
const depth = left.split('/').length - right.split('/').length;
|
|
857
|
+
return depth || (left < right ? -1 : left > right ? 1 : 0);
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
private bufferPin(): void {
|
|
862
|
+
const pin = this.pin;
|
|
863
|
+
if (!pin) return;
|
|
864
|
+
// A durable pin asserts presence, not churn: re-writing identical bytes
|
|
865
|
+
// re-arms the receiver's content GC for no change. Re-buffer only when a
|
|
866
|
+
// buffered record claims the path.
|
|
867
|
+
if (pin.durable && !this.records.has(pin.path) && !this.deletes.has(pin.path)) return;
|
|
868
|
+
this.buffer(pin.path, { kind: 'file', mode: 0o644, bytes: pin.bytes.slice(), meta: undefined });
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** An attempt the session never read or never answered: its call did not arrive, or its answer was lost. */
|
|
873
|
+
/** One wave's attempts (sendWaveAttempts). */
|
|
874
|
+
export interface WaveAttempts {
|
|
875
|
+
supervisor: WaveSupervisor;
|
|
876
|
+
/** The epoch each attempt is fenced under, asked before each one (null: unfenced). */
|
|
877
|
+
writer: () => Promise<string | null>;
|
|
878
|
+
/** A fresh stream of the wave's bytes for each attempt. */
|
|
879
|
+
open: () => ReadableStream<Uint8Array>;
|
|
880
|
+
/** A wave with a streamed source is sent once: its source is spent. */
|
|
881
|
+
streamed: boolean;
|
|
882
|
+
wave: number;
|
|
883
|
+
/** A sequenced writer's numbering, on every attempt's fence (WaveFence `seq`, `ack`). */
|
|
884
|
+
sequence?: { seq: number; ack: number };
|
|
885
|
+
/** The delegation the wave is written under (WaveSupervisor.writeBatchStream's `owner`). */
|
|
886
|
+
owner?: string;
|
|
887
|
+
/** The lost-call policy's timings (lost-call.ts); tests shorten them. */
|
|
888
|
+
retry?: { backoffMs: readonly number[]; stallMs: number; answerDeadlineMs: number };
|
|
889
|
+
/** Told before each re-send, with its lost-call attributes. */
|
|
890
|
+
resent?: (lost: Record<string, string | number>) => void;
|
|
891
|
+
/** The timers the watch and the backoff run on: a program's own may be its shims'. */
|
|
892
|
+
timers?: WaveTimers;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** setTimeout and clearTimeout, as a caller captured them. */
|
|
896
|
+
export interface WaveTimers {
|
|
897
|
+
setTimeout(callback: () => void, ms: number): unknown;
|
|
898
|
+
clearTimeout(timer: unknown): void;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const GLOBAL_TIMERS: WaveTimers = {
|
|
902
|
+
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
|
903
|
+
clearTimeout: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Send one wave, again while its transport is lost (see the module's
|
|
908
|
+
* comment), and answer with what the session answered. The one way every
|
|
909
|
+
* W7 producer sends: the wave writer, and a process's filesystem client.
|
|
910
|
+
*/
|
|
911
|
+
export async function sendWaveAttempts(options: WaveAttempts): Promise<unknown> {
|
|
912
|
+
const { backoffMs, stallMs, answerDeadlineMs } = options.retry
|
|
913
|
+
?? { backoffMs: LOST_CALL_RESEND_BACKOFF_MS, stallMs: LOST_STREAM_STALL_MS, answerDeadlineMs: LOST_STREAM_ANSWER_MS };
|
|
914
|
+
const timers = options.timers ?? GLOBAL_TIMERS;
|
|
915
|
+
for (let attempt = 0; ; attempt++) {
|
|
916
|
+
const writer = await options.writer();
|
|
917
|
+
const attemptStream = abortable(options.open(), stallMs, answerDeadlineMs, timers);
|
|
918
|
+
const fence: WaveFence | undefined = writer === null ? undefined : { writer, wave: options.wave, attempt: attempt + 1, ...options.sequence };
|
|
919
|
+
// Called as a method of the supervisor, never through .call/.apply: on an RPC stub those are remote method names too.
|
|
920
|
+
const answer = options.owner === undefined
|
|
921
|
+
? options.supervisor.writeBatchStream(attemptStream.stream, fence)
|
|
922
|
+
: options.supervisor.writeBatchStream(attemptStream.stream, fence, options.owner);
|
|
923
|
+
try {
|
|
924
|
+
return await Promise.race([answer, attemptStream.lost]);
|
|
925
|
+
} catch (error) {
|
|
926
|
+
const lost = error instanceof WaveLost || isLostFencedCall(error);
|
|
927
|
+
// Given up: the attempt reads nothing more either (its writer retires its epoch).
|
|
928
|
+
if (lost && (options.streamed || attempt >= backoffMs.length)) attemptStream.abort(error);
|
|
929
|
+
if (!lost || options.streamed || attempt >= backoffMs.length) throw error;
|
|
930
|
+
// The abandoned attempt can read nothing more, and its late answer is dropped.
|
|
931
|
+
attemptStream.abort(error);
|
|
932
|
+
answer.then(disposeRpcResource, () => {});
|
|
933
|
+
options.resent?.(lostCallAttributes({
|
|
934
|
+
operation: 'writeBatchStream',
|
|
935
|
+
attempt: attempt + 1,
|
|
936
|
+
of: backoffMs.length,
|
|
937
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
938
|
+
}));
|
|
939
|
+
await new Promise<void>((resolve) => { timers.setTimeout(resolve, retryDelayMs(backoffMs, attempt)); });
|
|
940
|
+
} finally {
|
|
941
|
+
attemptStream.settle();
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** A wave's encoded bytes as each attempt's stream (in SEND_SLICE_BYTES pieces, each a copy). */
|
|
947
|
+
export function waveAttemptsOf(bytes: Uint8Array): () => ReadableStream<Uint8Array> {
|
|
948
|
+
return piecesOf(bytes, SEND_SLICE_BYTES);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
class WaveLost extends Error {
|
|
952
|
+
constructor(message: string) {
|
|
953
|
+
super(message);
|
|
954
|
+
this.name = 'WaveLost';
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** An attempt's stream as the encoder makes it, read from `payload`. */
|
|
959
|
+
function encoderOf(payload: BatchWritePayload): () => ReadableStream<Uint8Array> {
|
|
960
|
+
return () => encodeWriteBatchStream(payload);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* An attempt's stream as `bytes` in `size`-byte pieces, each its own copy:
|
|
965
|
+
* the watched stream they pass through transfers what it enqueues, and the
|
|
966
|
+
* bytes stay whole for a re-send. (A function of its own, so the closure
|
|
967
|
+
* holds the bytes and nothing the encoder read them from.)
|
|
968
|
+
*/
|
|
969
|
+
function piecesOf(bytes: Uint8Array, size: number): () => ReadableStream<Uint8Array> {
|
|
970
|
+
return () => slices(bytes, size);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function slices(bytes: Uint8Array, size: number): ReadableStream<Uint8Array> {
|
|
974
|
+
let at = 0;
|
|
975
|
+
return new ReadableStream<Uint8Array>({
|
|
976
|
+
pull(controller) {
|
|
977
|
+
if (at >= bytes.byteLength) {
|
|
978
|
+
controller.close();
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const end = Math.min(bytes.byteLength, at + size);
|
|
982
|
+
controller.enqueue(bytes.slice(at, end));
|
|
983
|
+
at = end;
|
|
984
|
+
},
|
|
985
|
+
}, { highWaterMark: 0 });
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* One attempt's stream, watched from the writer's side: `lost` rejects when
|
|
990
|
+
* nothing has read it for `stallMs` before it ended, or no answer came
|
|
991
|
+
* `answerDeadlineMs` after it ended; `abort` errors it so the attempt reads
|
|
992
|
+
* nothing more. Reading is the bytes drained from the stream's queue: a
|
|
993
|
+
* reader taking a large piece in small reads drains it without a pull.
|
|
994
|
+
*/
|
|
995
|
+
function abortable(stream: ReadableStream<Uint8Array>, stallMs: number, answerDeadlineMs: number, timers: WaveTimers): {
|
|
996
|
+
stream: ReadableStream<Uint8Array>;
|
|
997
|
+
lost: Promise<never>;
|
|
998
|
+
abort(reason: unknown): void;
|
|
999
|
+
settle(): void;
|
|
1000
|
+
} {
|
|
1001
|
+
const reader = stream.getReader();
|
|
1002
|
+
let target: ReadableByteStreamController | null = null;
|
|
1003
|
+
let timer: unknown = null;
|
|
1004
|
+
let declareLost: (error: WaveLost) => void = () => {};
|
|
1005
|
+
const lost = new Promise<never>((_, reject) => { declareLost = reject; });
|
|
1006
|
+
lost.catch(() => {});
|
|
1007
|
+
const watch = (ms: number, message: string): void => {
|
|
1008
|
+
if (timer !== null) timers.clearTimeout(timer);
|
|
1009
|
+
timer = timers.setTimeout(() => declareLost(new WaveLost(message)), ms);
|
|
1010
|
+
};
|
|
1011
|
+
/** Bytes enqueued and not yet read (the queue's total, as desiredSize below a mark of 0). */
|
|
1012
|
+
const queued = (): number => Math.max(0, -(target?.desiredSize ?? 0));
|
|
1013
|
+
// Lost when a stall period passes with nothing drained from the queue.
|
|
1014
|
+
const watchReads = (): void => {
|
|
1015
|
+
if (timer !== null) timers.clearTimeout(timer);
|
|
1016
|
+
const unread = queued();
|
|
1017
|
+
timer = timers.setTimeout(() => {
|
|
1018
|
+
if (queued() < unread) watchReads();
|
|
1019
|
+
else declareLost(new WaveLost(`writeBatchStream stalled: nothing read it for ${stallMs} ms`));
|
|
1020
|
+
}, stallMs);
|
|
1021
|
+
};
|
|
1022
|
+
watchReads();
|
|
1023
|
+
let sourceDone = false;
|
|
1024
|
+
const source: UnderlyingByteSource = {
|
|
1025
|
+
type: 'bytes',
|
|
1026
|
+
start(controller) {
|
|
1027
|
+
target = controller;
|
|
1028
|
+
},
|
|
1029
|
+
async pull(controller) {
|
|
1030
|
+
const next = sourceDone ? null : await reader.read();
|
|
1031
|
+
if (next === null || next.done) {
|
|
1032
|
+
sourceDone = true;
|
|
1033
|
+
// The stream has ended once its reader asks for more with nothing
|
|
1034
|
+
// queued; a pull may come while it still drains the last piece.
|
|
1035
|
+
if (queued() > 0) return;
|
|
1036
|
+
watch(answerDeadlineMs, `writeBatchStream unanswered ${answerDeadlineMs} ms after its stream ended`);
|
|
1037
|
+
controller.close();
|
|
1038
|
+
// A reader waiting with its own buffer is answered "done" only so.
|
|
1039
|
+
controller.byobRequest?.respond(0);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
controller.enqueue(next.value);
|
|
1043
|
+
watchReads();
|
|
1044
|
+
},
|
|
1045
|
+
cancel(reason) {
|
|
1046
|
+
return reader.cancel(reason);
|
|
1047
|
+
},
|
|
1048
|
+
};
|
|
1049
|
+
return {
|
|
1050
|
+
stream: new ReadableStream<Uint8Array>(source as never, { highWaterMark: 0 }),
|
|
1051
|
+
lost,
|
|
1052
|
+
abort(reason) {
|
|
1053
|
+
try { target?.error(reason); } catch { /* already closed */ }
|
|
1054
|
+
reader.cancel(reason).catch(() => {});
|
|
1055
|
+
},
|
|
1056
|
+
settle() {
|
|
1057
|
+
if (timer !== null) timers.clearTimeout(timer);
|
|
1058
|
+
timer = null;
|
|
1059
|
+
},
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
export function createWaveWriter<Meta = undefined>(options: WaveWriterOptions<Meta>): WaveWriter<Meta> {
|
|
1064
|
+
return new WaveWriter(options);
|
|
1065
|
+
}
|