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