@smart-byte/leani-sdk 0.1.0-rc.1 → 0.1.0-rc.3
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 +23 -8
- package/dist/backfill.d.ts +39 -3
- package/dist/backfill.js +295 -191
- package/dist/backfill.js.map +1 -1
- package/dist/errors.d.ts +10 -1
- package/dist/errors.js +13 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +58 -5
- package/dist/index.js +186 -150
- package/dist/index.js.map +1 -1
- package/dist/openapi.generated.d.ts +681 -92
- package/dist/openapi.generated.js.map +1 -1
- package/dist/transport.d.ts +39 -0
- package/dist/transport.js +166 -0
- package/dist/transport.js.map +1 -0
- package/package.json +11 -8
- package/dist/backfill.d.ts.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/openapi.generated.d.ts.map +0 -1
package/dist/backfill.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { LeaniError, responseError } from "./errors.js";
|
|
2
|
-
export { LeaniError } from "./errors.js";
|
|
1
|
+
import { LeaniError, TransportError, responseError } from "./errors.js";
|
|
2
|
+
export { LeaniError, TransportError } from "./errors.js";
|
|
3
|
+
import { concatBytes, idleWatchdog, readChunks, readJson, readText, resolveUnder, send, withDeadline, } from "./transport.js";
|
|
3
4
|
const BACKFILL_BATCHING_PROFILES = {
|
|
4
5
|
latency: {
|
|
5
6
|
targetEncodedBytes: 256 * 1024,
|
|
@@ -29,6 +30,22 @@ const BACKFILL_BATCHING_PROFILES = {
|
|
|
29
30
|
export function backfillBatchingProfile(profile) {
|
|
30
31
|
return { ...BACKFILL_BATCHING_PROFILES[profile] };
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* The `fill_missing` subscription for the blocks a live gap notice
|
|
35
|
+
* announces, its `LiveGapChangeData`, or any other range the destination
|
|
36
|
+
* lacks. Its idempotency key derives from the consumer and the range, so
|
|
37
|
+
* creating it again, as after the notice is delivered again, returns the
|
|
38
|
+
* same subscription.
|
|
39
|
+
*/
|
|
40
|
+
export function liveGapBackfill(range, request) {
|
|
41
|
+
return {
|
|
42
|
+
...request,
|
|
43
|
+
mode: "fill_missing",
|
|
44
|
+
fromBlock: range.fromBlock,
|
|
45
|
+
toBlock: range.toBlock,
|
|
46
|
+
idempotencyKey: `live-gap:${request.consumer.id}:${range.fromBlock}-${range.toBlock}`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
32
49
|
export class BackfillStreamError extends Error {
|
|
33
50
|
code;
|
|
34
51
|
earliestAvailableSequence;
|
|
@@ -53,18 +70,19 @@ export function createBackfillSubscriptionClient(options) {
|
|
|
53
70
|
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
54
71
|
assertPositiveInteger(timeoutMs, "timeoutMs");
|
|
55
72
|
const requestJson = async (method, path, body, signal, credential, sessionToken) => {
|
|
56
|
-
const
|
|
73
|
+
const headers = requestHeaders(options.token, "application/json", credential, sessionToken);
|
|
74
|
+
const response = await send(fetchImpl, resolveUnder(baseUrl, path), {
|
|
57
75
|
method,
|
|
58
|
-
headers:
|
|
76
|
+
headers: method === "GET" || body !== undefined
|
|
77
|
+
? headers
|
|
78
|
+
: markLeaniRequest(headers),
|
|
59
79
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
60
|
-
signal: signal
|
|
61
|
-
|
|
62
|
-
: AbortSignal.timeout(timeoutMs),
|
|
63
|
-
});
|
|
80
|
+
signal: withDeadline(signal, timeoutMs),
|
|
81
|
+
}, signal);
|
|
64
82
|
if (!response.ok) {
|
|
65
83
|
throw await responseError(response);
|
|
66
84
|
}
|
|
67
|
-
return (
|
|
85
|
+
return readJson(response, signal);
|
|
68
86
|
};
|
|
69
87
|
return Object.freeze({
|
|
70
88
|
baseUrl,
|
|
@@ -127,29 +145,21 @@ export function createBackfillSubscriptionClient(options) {
|
|
|
127
145
|
},
|
|
128
146
|
inspect: (subscription, inspectOptions) => requestJson("GET", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}`, undefined, inspectOptions?.signal),
|
|
129
147
|
cancel: (subscription, cancelOptions) => requestJson("POST", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}/cancel`, undefined, cancelOptions?.signal),
|
|
130
|
-
|
|
148
|
+
retry: (subscription, retryOptions) => requestJson("POST", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}/retry`, undefined, retryOptions?.signal),
|
|
149
|
+
delete: (subscription, deleteOptions) => requestJson("DELETE", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}${deleteOptions?.discardUnacknowledged ? "?discardUnacknowledged=true" : ""}`, undefined, deleteOptions?.signal),
|
|
131
150
|
changes: (subscription, consumer, changeOptions = {}) => {
|
|
132
151
|
const path = consumerPath(subscription, consumer, "changes");
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
return fetchImpl(url, {
|
|
138
|
-
headers: requestHeaders(options.token, "application/json", changeOptions.credential),
|
|
139
|
-
signal: changeOptions.signal,
|
|
140
|
-
}).then(async (response) => {
|
|
141
|
-
if (!response.ok) {
|
|
142
|
-
throw await responseError(response);
|
|
143
|
-
}
|
|
144
|
-
return (await response.json());
|
|
145
|
-
});
|
|
152
|
+
const query = changeOptions.limit === undefined
|
|
153
|
+
? ""
|
|
154
|
+
: `?limit=${assertPositiveInteger(changeOptions.limit, "limit")}`;
|
|
155
|
+
return requestJson("GET", `${path}${query}`, undefined, changeOptions.signal, changeOptions.credential);
|
|
146
156
|
},
|
|
147
|
-
stream: (subscription, consumer, streamOptions = {}) => openBackfillStream(fetchImpl, baseUrl, options.token, subscription, consumer, streamOptions.credential, streamOptions.batching, streamOptions.signal),
|
|
148
|
-
streamLive: (processor, consumer, streamOptions = {}) => openLiveStream(fetchImpl, baseUrl, options.token, processor, consumer, streamOptions.credential, streamOptions.signal),
|
|
149
|
-
subscribe: (request) => openMultiLaneDelivery(fetchImpl, baseUrl, options.token, request),
|
|
157
|
+
stream: (subscription, consumer, streamOptions = {}) => openBackfillStream(fetchImpl, baseUrl, options.token, timeoutMs, subscription, consumer, streamOptions.credential, streamOptions.batching, streamOptions.signal),
|
|
158
|
+
streamLive: (processor, consumer, streamOptions = {}) => openLiveStream(fetchImpl, baseUrl, options.token, timeoutMs, processor, consumer, streamOptions.credential, streamOptions.signal),
|
|
159
|
+
subscribe: (request) => openMultiLaneDelivery(fetchImpl, baseUrl, options.token, timeoutMs, request),
|
|
150
160
|
});
|
|
151
161
|
}
|
|
152
|
-
async function openMultiLaneDelivery(fetchImpl, baseUrl, token, request) {
|
|
162
|
+
async function openMultiLaneDelivery(fetchImpl, baseUrl, token, timeoutMs, request) {
|
|
153
163
|
const processor = assertIdentity(request.processor, "processor");
|
|
154
164
|
const consumer = assertIdentity(request.consumer, "consumer");
|
|
155
165
|
const history = request.lanes.history ?? [];
|
|
@@ -164,11 +174,11 @@ async function openMultiLaneDelivery(fetchImpl, baseUrl, token, request) {
|
|
|
164
174
|
const lanes = [];
|
|
165
175
|
try {
|
|
166
176
|
if (request.lanes.live) {
|
|
167
|
-
const open = (signal = request.signal) => openLiveStream(fetchImpl, baseUrl, token, processor, consumer, request.credential, signal);
|
|
177
|
+
const open = (signal = request.signal) => openLiveStream(fetchImpl, baseUrl, token, timeoutMs, processor, consumer, request.credential, signal);
|
|
168
178
|
lanes.push(unifiedLiveLane(await open(), open, request.signal));
|
|
169
179
|
}
|
|
170
180
|
for (const subscriptionId of historyIds) {
|
|
171
|
-
const open = (signal = request.signal) => openBackfillStream(fetchImpl, baseUrl, token, subscriptionId, consumer, request.credential, request.batching, signal);
|
|
181
|
+
const open = (signal = request.signal) => openBackfillStream(fetchImpl, baseUrl, token, timeoutMs, subscriptionId, consumer, request.credential, request.batching, signal);
|
|
172
182
|
lanes.push(unifiedHistoryLane(subscriptionId, await open(), open, request.signal));
|
|
173
183
|
}
|
|
174
184
|
}
|
|
@@ -289,33 +299,10 @@ function unifiedHistoryLane(subscriptionId, initialSession, open, signal) {
|
|
|
289
299
|
return { done: true, value: undefined };
|
|
290
300
|
if (next.value.type === "heartbeat")
|
|
291
301
|
continue;
|
|
292
|
-
|
|
293
|
-
complete = true;
|
|
294
|
-
const record = next.value;
|
|
295
|
-
return {
|
|
296
|
-
done: false,
|
|
297
|
-
value: {
|
|
298
|
-
laneKind: "history",
|
|
299
|
-
laneId: subscriptionId,
|
|
300
|
-
streamId: record.streamId,
|
|
301
|
-
ackCursor: record.cursor,
|
|
302
|
-
events: [],
|
|
303
|
-
completion: record,
|
|
304
|
-
record,
|
|
305
|
-
},
|
|
306
|
-
};
|
|
307
|
-
}
|
|
308
|
-
const record = next.value;
|
|
302
|
+
complete = next.value.type === "backfill_complete";
|
|
309
303
|
return {
|
|
310
304
|
done: false,
|
|
311
|
-
value:
|
|
312
|
-
laneKind: "history",
|
|
313
|
-
laneId: subscriptionId,
|
|
314
|
-
streamId: record.streamId,
|
|
315
|
-
ackCursor: record.acknowledgeableCursor,
|
|
316
|
-
events: record.changes,
|
|
317
|
-
record,
|
|
318
|
-
},
|
|
305
|
+
value: historyDeliveryBatch(subscriptionId, next.value),
|
|
319
306
|
};
|
|
320
307
|
}
|
|
321
308
|
},
|
|
@@ -323,6 +310,28 @@ function unifiedHistoryLane(subscriptionId, initialSession, open, signal) {
|
|
|
323
310
|
close: connection.close,
|
|
324
311
|
};
|
|
325
312
|
}
|
|
313
|
+
/** A history batch or the completion with the cursor that acknowledges it. */
|
|
314
|
+
function historyDeliveryBatch(laneId, record) {
|
|
315
|
+
if (record.type === "backfill_complete") {
|
|
316
|
+
return {
|
|
317
|
+
laneKind: "history",
|
|
318
|
+
laneId,
|
|
319
|
+
streamId: record.streamId,
|
|
320
|
+
ackCursor: record.cursor,
|
|
321
|
+
events: [],
|
|
322
|
+
completion: record,
|
|
323
|
+
record,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
laneKind: "history",
|
|
328
|
+
laneId,
|
|
329
|
+
streamId: record.streamId,
|
|
330
|
+
ackCursor: record.acknowledgeableCursor,
|
|
331
|
+
events: record.changes,
|
|
332
|
+
record,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
326
335
|
function reconnectingLaneConnection(initialSession, open, signal) {
|
|
327
336
|
const expected = initialSession.hello;
|
|
328
337
|
const stop = new AbortController();
|
|
@@ -399,8 +408,10 @@ function reconnectingLaneConnection(initialSession, open, signal) {
|
|
|
399
408
|
closed = true;
|
|
400
409
|
stop.abort();
|
|
401
410
|
await session.close();
|
|
411
|
+
// A stopped reconnect rejects with the abort reason, which may be the
|
|
412
|
+
// caller's own, of any type.
|
|
402
413
|
await reconnectTask?.catch((error) => {
|
|
403
|
-
if (!
|
|
414
|
+
if (!reconnectSignal.aborted)
|
|
404
415
|
throw error;
|
|
405
416
|
});
|
|
406
417
|
},
|
|
@@ -418,26 +429,16 @@ function validateReplacementHello(expected, replacement) {
|
|
|
418
429
|
throw streamProtocolError("live", "replacement connection changed its durable lane identity");
|
|
419
430
|
}
|
|
420
431
|
}
|
|
432
|
+
/**
|
|
433
|
+
* Classified by where the failure came from, never by its message: the SDK
|
|
434
|
+
* reports every failure below HTTP as a retryable TransportError, and a
|
|
435
|
+
* caller's abort, a stream record, or a local check is never retried.
|
|
436
|
+
*/
|
|
421
437
|
function isRetryableDeliveryDisconnect(error) {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
error.code === "consumer_session_lost";
|
|
427
|
-
}
|
|
428
|
-
if (!(error instanceof Error))
|
|
429
|
-
return false;
|
|
430
|
-
if (error.name === "consumer_session_active" ||
|
|
431
|
-
error.name === "consumer_session_lost") {
|
|
432
|
-
return true;
|
|
433
|
-
}
|
|
434
|
-
if (error.name === "NetworkError" || error.name === "TimeoutError")
|
|
435
|
-
return true;
|
|
436
|
-
return (error.name === "TypeError" &&
|
|
437
|
-
/fetch|network|load failed|socket|connection/i.test(error.message));
|
|
438
|
-
}
|
|
439
|
-
function isAbortError(error) {
|
|
440
|
-
return error instanceof Error && error.name === "AbortError";
|
|
438
|
+
return (error instanceof LeaniError &&
|
|
439
|
+
(error.retryable ||
|
|
440
|
+
error.code === "consumer_session_active" ||
|
|
441
|
+
error.code === "consumer_session_lost"));
|
|
441
442
|
}
|
|
442
443
|
async function waitForReconnectDelay(delayMs, signal) {
|
|
443
444
|
if (signal.aborted) {
|
|
@@ -511,8 +512,8 @@ function iterateUnifiedLanes(lanes, claim) {
|
|
|
511
512
|
},
|
|
512
513
|
};
|
|
513
514
|
}
|
|
514
|
-
async function openBackfillStream(fetchImpl, baseUrl, token, subscription, consumer, credential, batching, signal) {
|
|
515
|
-
const session = await openDeliverySession(fetchImpl, baseUrl, token, backfillStreamPath(subscription, consumer, batching), consumerPath(subscription, consumer, "ack"), consumerPath(subscription, consumer, "lease"), credential, signal, "backfill");
|
|
515
|
+
async function openBackfillStream(fetchImpl, baseUrl, token, timeoutMs, subscription, consumer, credential, batching, signal) {
|
|
516
|
+
const session = await openDeliverySession(fetchImpl, baseUrl, token, timeoutMs, backfillStreamPath(subscription, consumer, batching), consumerPath(subscription, consumer, "ack"), consumerPath(subscription, consumer, "lease"), credential, signal, "backfill");
|
|
516
517
|
const batches = async function* () {
|
|
517
518
|
for await (const record of session) {
|
|
518
519
|
if (record.type === "batch") {
|
|
@@ -520,6 +521,15 @@ async function openBackfillStream(fetchImpl, baseUrl, token, subscription, consu
|
|
|
520
521
|
}
|
|
521
522
|
}
|
|
522
523
|
};
|
|
524
|
+
// The stream ends once the completion is acknowledged, so iteration
|
|
525
|
+
// continues past it rather than closing the session first.
|
|
526
|
+
const deliveryBatches = async function* () {
|
|
527
|
+
for await (const record of session) {
|
|
528
|
+
if (record.type !== "heartbeat") {
|
|
529
|
+
yield historyDeliveryBatch(session.hello.subscriptionId, record);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
};
|
|
523
533
|
const events = async function* () {
|
|
524
534
|
for await (const batch of batches()) {
|
|
525
535
|
yield* batch.changes;
|
|
@@ -530,90 +540,119 @@ async function openBackfillStream(fetchImpl, baseUrl, token, subscription, consu
|
|
|
530
540
|
acknowledge: session.acknowledge.bind(session),
|
|
531
541
|
close: session.close.bind(session),
|
|
532
542
|
batches,
|
|
543
|
+
deliveryBatches,
|
|
533
544
|
events,
|
|
534
545
|
[Symbol.asyncIterator]: session[Symbol.asyncIterator].bind(session),
|
|
535
546
|
});
|
|
536
547
|
}
|
|
537
|
-
async function openLiveStream(fetchImpl, baseUrl, token, processor, consumer, credential, signal) {
|
|
538
|
-
return openDeliverySession(fetchImpl, baseUrl, token, liveConsumerPath(processor, consumer, "stream"), liveConsumerPath(processor, consumer, "ack"), liveConsumerPath(processor, consumer, "lease"), credential, signal, "live");
|
|
548
|
+
async function openLiveStream(fetchImpl, baseUrl, token, timeoutMs, processor, consumer, credential, signal) {
|
|
549
|
+
return openDeliverySession(fetchImpl, baseUrl, token, timeoutMs, liveConsumerPath(processor, consumer, "stream"), liveConsumerPath(processor, consumer, "ack"), liveConsumerPath(processor, consumer, "lease"), credential, signal, "live");
|
|
539
550
|
}
|
|
540
|
-
async function openDeliverySession(fetchImpl, baseUrl, token, streamPath, acknowledgePath, renewPath, credential, signal, streamKind) {
|
|
541
|
-
const
|
|
542
|
-
|
|
551
|
+
async function openDeliverySession(fetchImpl, baseUrl, token, timeoutMs, streamPath, acknowledgePath, renewPath, credential, signal, streamKind) {
|
|
552
|
+
const connection = new AbortController();
|
|
553
|
+
// A connection that fails on its own, rather than through the caller's
|
|
554
|
+
// abort or close(), keeps the failure for the iterator and acknowledge().
|
|
555
|
+
let failure;
|
|
556
|
+
const fail = (error) => {
|
|
557
|
+
if (connection.signal.aborted) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
failure = error;
|
|
561
|
+
connection.abort(error);
|
|
562
|
+
};
|
|
563
|
+
const stopFromCaller = () => connection.abort(signal?.reason);
|
|
564
|
+
signal?.addEventListener("abort", stopFromCaller, { once: true });
|
|
543
565
|
if (signal?.aborted) {
|
|
544
566
|
stopFromCaller();
|
|
545
567
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
568
|
+
// Until its hello arrives, the stream may stay silent as long as a JSON
|
|
569
|
+
// request may take; afterwards, for three heartbeat intervals.
|
|
570
|
+
let watchdog = idleWatchdog(timeoutMs, () => fail(new TransportError(`${streamKind} stream sent no hello within ${timeoutMs} ms`)));
|
|
571
|
+
let records;
|
|
572
|
+
let wireHello;
|
|
573
|
+
let sessionToken;
|
|
574
|
+
let leaseTtlMs;
|
|
575
|
+
let heartbeatIntervalMs;
|
|
576
|
+
try {
|
|
577
|
+
watchdog.arm();
|
|
578
|
+
const response = await send(fetchImpl, resolveUnder(baseUrl, streamPath), {
|
|
579
|
+
headers: requestHeaders(token, "application/x-ndjson", credential),
|
|
580
|
+
signal: connection.signal,
|
|
581
|
+
}, signal);
|
|
582
|
+
if (!response.ok) {
|
|
583
|
+
throw await responseError(response);
|
|
584
|
+
}
|
|
585
|
+
if (!response.body) {
|
|
586
|
+
throw new BackfillStreamError({
|
|
587
|
+
type: "error",
|
|
588
|
+
code: "stream_body_missing",
|
|
589
|
+
message: `${streamKind} stream response has no body`,
|
|
590
|
+
earliestAvailableSequence: null,
|
|
591
|
+
latestAvailableSequence: null,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
records = ndjsonRecords(response.body, connection.signal, () => watchdog.arm());
|
|
595
|
+
const first = await records.next();
|
|
596
|
+
if (first.done) {
|
|
597
|
+
throw streamProtocolError(streamKind, "stream ended before its hello record");
|
|
598
|
+
}
|
|
599
|
+
const firstType = first.value.type;
|
|
600
|
+
if (firstType === "error" || firstType === "reset_required") {
|
|
601
|
+
throw new BackfillStreamError(first.value);
|
|
602
|
+
}
|
|
603
|
+
if (firstType !== "hello") {
|
|
604
|
+
throw streamProtocolError(streamKind, "first stream record is not hello");
|
|
605
|
+
}
|
|
606
|
+
wireHello = first.value;
|
|
607
|
+
sessionToken = assertIdentity(wireHello.sessionToken, "stream hello sessionToken");
|
|
608
|
+
leaseTtlMs = parseHelloDuration(wireHello.leaseTtlMs, "leaseTtlMs");
|
|
609
|
+
heartbeatIntervalMs = parseHelloDuration(wireHello.heartbeatIntervalMs, "heartbeatIntervalMs");
|
|
573
610
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
connectionStop.abort();
|
|
611
|
+
catch (error) {
|
|
612
|
+
connection.abort();
|
|
577
613
|
signal?.removeEventListener("abort", stopFromCaller);
|
|
578
|
-
|
|
614
|
+
await records?.return(undefined).catch(() => undefined);
|
|
615
|
+
throw failure ?? error;
|
|
579
616
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
signal?.removeEventListener("abort", stopFromCaller);
|
|
583
|
-
throw streamProtocolError(streamKind, "first stream record is not hello");
|
|
617
|
+
finally {
|
|
618
|
+
watchdog.disarm();
|
|
584
619
|
}
|
|
585
|
-
const
|
|
586
|
-
const sessionToken = assertIdentity(wireHello.sessionToken, "stream hello sessionToken");
|
|
587
|
-
const leaseTtlMs = parseLeaseTtl(wireHello.leaseTtlMs);
|
|
620
|
+
const stream = records;
|
|
588
621
|
const { sessionToken: _sessionToken, ...publicHello } = wireHello;
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
622
|
+
const idleMs = 3 * heartbeatIntervalMs;
|
|
623
|
+
watchdog = idleWatchdog(idleMs, () => fail(new TransportError(`${streamKind} stream sent nothing for ${idleMs} ms`)));
|
|
624
|
+
const renewal = new AbortController();
|
|
625
|
+
const renewalTask = renewSessionUntilStopped(fetchImpl, baseUrl, token, timeoutMs, renewPath, credential, sessionToken, leaseTtlMs, renewal.signal).catch((error) => {
|
|
626
|
+
// A lease that cannot be renewed ends the connection at once instead of
|
|
627
|
+
// at the next record, which may never come.
|
|
628
|
+
if (!renewal.signal.aborted) {
|
|
629
|
+
fail(error);
|
|
594
630
|
}
|
|
595
631
|
});
|
|
596
632
|
let iteratorClaimed = false;
|
|
597
633
|
let closeTask;
|
|
598
634
|
const close = () => {
|
|
599
635
|
closeTask ??= (async () => {
|
|
600
|
-
|
|
601
|
-
|
|
636
|
+
renewal.abort();
|
|
637
|
+
watchdog.disarm();
|
|
638
|
+
connection.abort();
|
|
602
639
|
signal?.removeEventListener("abort", stopFromCaller);
|
|
603
640
|
try {
|
|
604
|
-
await
|
|
641
|
+
await stream.return(undefined);
|
|
605
642
|
}
|
|
606
643
|
catch (error) {
|
|
607
|
-
if (!
|
|
644
|
+
if (!connection.signal.aborted) {
|
|
608
645
|
throw error;
|
|
609
646
|
}
|
|
610
647
|
}
|
|
611
648
|
await renewalTask;
|
|
612
649
|
try {
|
|
613
|
-
await fetchImpl
|
|
650
|
+
const released = await send(fetchImpl, resolveUnder(baseUrl, renewPath), {
|
|
614
651
|
method: "DELETE",
|
|
615
|
-
headers: requestHeaders(token, "application/json", credential, sessionToken),
|
|
616
|
-
|
|
652
|
+
headers: markLeaniRequest(requestHeaders(token, "application/json", credential, sessionToken)),
|
|
653
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
654
|
+
}, undefined);
|
|
655
|
+
void released.body?.cancel().catch(() => undefined);
|
|
617
656
|
}
|
|
618
657
|
catch {
|
|
619
658
|
// The lease also expires and is passively released when the stream
|
|
@@ -629,14 +668,27 @@ async function openDeliverySession(fetchImpl, baseUrl, token, streamPath, acknow
|
|
|
629
668
|
iteratorClaimed = true;
|
|
630
669
|
try {
|
|
631
670
|
while (true) {
|
|
632
|
-
|
|
633
|
-
|
|
671
|
+
let next;
|
|
672
|
+
// Only a pending read counts silence, not the time the application
|
|
673
|
+
// spends on a record.
|
|
674
|
+
watchdog.arm();
|
|
675
|
+
try {
|
|
676
|
+
next = await stream.next();
|
|
634
677
|
}
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
if (
|
|
638
|
-
|
|
678
|
+
catch (error) {
|
|
679
|
+
// The caller's abort and close() end the iteration quietly.
|
|
680
|
+
if (failure === undefined && connection.signal.aborted) {
|
|
681
|
+
return;
|
|
639
682
|
}
|
|
683
|
+
throw failure ?? error;
|
|
684
|
+
}
|
|
685
|
+
finally {
|
|
686
|
+
watchdog.disarm();
|
|
687
|
+
}
|
|
688
|
+
if (failure !== undefined) {
|
|
689
|
+
throw failure;
|
|
690
|
+
}
|
|
691
|
+
if (next.done) {
|
|
640
692
|
return;
|
|
641
693
|
}
|
|
642
694
|
const record = next.value;
|
|
@@ -660,19 +712,20 @@ async function openDeliverySession(fetchImpl, baseUrl, token, streamPath, acknow
|
|
|
660
712
|
if (closeTask !== undefined) {
|
|
661
713
|
throw new TypeError("delivery session is closed");
|
|
662
714
|
}
|
|
663
|
-
if (
|
|
664
|
-
throw
|
|
715
|
+
if (failure !== undefined) {
|
|
716
|
+
throw failure;
|
|
665
717
|
}
|
|
666
|
-
const
|
|
718
|
+
const caller = acknowledgeOptions?.signal;
|
|
719
|
+
const acknowledge = await send(fetchImpl, resolveUnder(baseUrl, acknowledgePath), {
|
|
667
720
|
method: "POST",
|
|
668
721
|
headers: requestHeaders(token, "application/json", credential, sessionToken),
|
|
669
722
|
body: JSON.stringify({ cursor: assertIdentity(cursor, "cursor") }),
|
|
670
|
-
signal:
|
|
671
|
-
});
|
|
723
|
+
signal: withDeadline(caller, timeoutMs),
|
|
724
|
+
}, caller);
|
|
672
725
|
if (!acknowledge.ok) {
|
|
673
726
|
throw await responseError(acknowledge);
|
|
674
727
|
}
|
|
675
|
-
return (
|
|
728
|
+
return readJson(acknowledge, caller);
|
|
676
729
|
},
|
|
677
730
|
close,
|
|
678
731
|
[Symbol.asyncIterator]: iterate,
|
|
@@ -687,79 +740,125 @@ function streamProtocolError(streamKind, message) {
|
|
|
687
740
|
latestAvailableSequence: null,
|
|
688
741
|
});
|
|
689
742
|
}
|
|
690
|
-
async function renewSessionUntilStopped(fetchImpl, baseUrl, token, renewPath, credential, sessionToken, leaseTtlMs, stop) {
|
|
743
|
+
async function renewSessionUntilStopped(fetchImpl, baseUrl, token, timeoutMs, renewPath, credential, sessionToken, leaseTtlMs, stop) {
|
|
691
744
|
const intervalMs = Math.max(50, Math.floor(leaseTtlMs / 3));
|
|
745
|
+
const firstRetryMs = Math.max(10, Math.floor(intervalMs / 10));
|
|
746
|
+
// The node renewed the lease no earlier than the request that renewed it.
|
|
747
|
+
let renewedAt = performance.now();
|
|
748
|
+
let delayMs = intervalMs;
|
|
749
|
+
let retryMs = firstRetryMs;
|
|
692
750
|
while (!stop.aborted) {
|
|
693
|
-
await abortableDelay(
|
|
751
|
+
await abortableDelay(delayMs, stop);
|
|
694
752
|
if (stop.aborted) {
|
|
695
753
|
return;
|
|
696
754
|
}
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
755
|
+
const attemptedAt = performance.now();
|
|
756
|
+
const leaseLeftMs = renewedAt + leaseTtlMs - attemptedAt;
|
|
757
|
+
try {
|
|
758
|
+
const response = await send(fetchImpl, resolveUnder(baseUrl, renewPath), {
|
|
759
|
+
method: "POST",
|
|
760
|
+
headers: markLeaniRequest(requestHeaders(token, "application/json", credential, sessionToken)),
|
|
761
|
+
// An answer after the lease lapsed could not keep it.
|
|
762
|
+
signal: withDeadline(stop, Math.max(1, Math.min(timeoutMs, Math.floor(leaseLeftMs)))),
|
|
763
|
+
}, stop);
|
|
764
|
+
if (!response.ok) {
|
|
765
|
+
throw await responseError(response);
|
|
766
|
+
}
|
|
767
|
+
await readText(response, stop);
|
|
768
|
+
renewedAt = attemptedAt;
|
|
769
|
+
delayMs = intervalMs;
|
|
770
|
+
retryMs = firstRetryMs;
|
|
771
|
+
}
|
|
772
|
+
catch (error) {
|
|
773
|
+
// Retry a transient failure, with backoff, while the lease holds.
|
|
774
|
+
const retryable = error instanceof LeaniError && error.retryable;
|
|
775
|
+
if (stop.aborted ||
|
|
776
|
+
!retryable ||
|
|
777
|
+
renewedAt + leaseTtlMs - performance.now() <= retryMs) {
|
|
778
|
+
throw error;
|
|
779
|
+
}
|
|
780
|
+
delayMs = retryMs;
|
|
781
|
+
retryMs *= 2;
|
|
704
782
|
}
|
|
705
|
-
await response.arrayBuffer();
|
|
706
783
|
}
|
|
707
784
|
}
|
|
708
785
|
function abortableDelay(delayMs, signal) {
|
|
709
786
|
return new Promise((resolve) => {
|
|
710
|
-
const
|
|
711
|
-
signal.addEventListener("abort", () => {
|
|
787
|
+
const finish = () => {
|
|
712
788
|
clearTimeout(timeout);
|
|
789
|
+
signal.removeEventListener("abort", finish);
|
|
713
790
|
resolve();
|
|
714
|
-
}
|
|
791
|
+
};
|
|
792
|
+
const timeout = setTimeout(finish, delayMs);
|
|
793
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
715
794
|
});
|
|
716
795
|
}
|
|
717
|
-
function
|
|
718
|
-
const
|
|
719
|
-
if (!Number.isSafeInteger(
|
|
720
|
-
throw new TypeError(
|
|
796
|
+
function parseHelloDuration(value, field) {
|
|
797
|
+
const milliseconds = Number(value);
|
|
798
|
+
if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) {
|
|
799
|
+
throw new TypeError(`stream hello contains an invalid ${field}`);
|
|
721
800
|
}
|
|
722
|
-
return
|
|
801
|
+
return milliseconds;
|
|
723
802
|
}
|
|
724
803
|
export async function* parseNdjson(body, signal) {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
804
|
+
yield* ndjsonRecords(body, signal);
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* The largest NDJSON record the client buffers: the node's default buffer
|
|
808
|
+
* for a batch's changes, 64 MiB, and room for the batch's own fields.
|
|
809
|
+
*/
|
|
810
|
+
const MAX_NDJSON_RECORD_BYTES = 65 * 1024 * 1024;
|
|
811
|
+
const LINE_FEED = 0x0a;
|
|
812
|
+
/**
|
|
813
|
+
* Split NDJSON records at line feeds, scanning each byte once. A line feed is
|
|
814
|
+
* ASCII, so a scan by byte never splits a UTF-8 character.
|
|
815
|
+
*/
|
|
816
|
+
async function* ndjsonRecords(body, signal, onChunk) {
|
|
730
817
|
const decoder = new TextDecoder();
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
818
|
+
// The bytes received so far of the record in progress.
|
|
819
|
+
let pending = [];
|
|
820
|
+
let pendingBytes = 0;
|
|
821
|
+
for await (const chunk of readChunks(body, signal, onChunk)) {
|
|
822
|
+
let lineStart = 0;
|
|
823
|
+
let newline = chunk.indexOf(LINE_FEED);
|
|
824
|
+
while (newline >= 0) {
|
|
825
|
+
const tail = chunk.subarray(lineStart, newline);
|
|
826
|
+
if (pendingBytes + tail.length > MAX_NDJSON_RECORD_BYTES) {
|
|
827
|
+
throw ndjsonRecordTooLarge();
|
|
736
828
|
}
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
}
|
|
746
|
-
newline = buffer.indexOf("\n");
|
|
829
|
+
const line = decoder
|
|
830
|
+
.decode(concatBytes(pending, pendingBytes, tail))
|
|
831
|
+
.trim();
|
|
832
|
+
pending = [];
|
|
833
|
+
pendingBytes = 0;
|
|
834
|
+
lineStart = newline + 1;
|
|
835
|
+
if (line) {
|
|
836
|
+
yield JSON.parse(line);
|
|
747
837
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
838
|
+
newline = chunk.indexOf(LINE_FEED, lineStart);
|
|
839
|
+
}
|
|
840
|
+
const rest = chunk.subarray(lineStart);
|
|
841
|
+
if (rest.length > 0) {
|
|
842
|
+
pendingBytes += rest.length;
|
|
843
|
+
if (pendingBytes > MAX_NDJSON_RECORD_BYTES) {
|
|
844
|
+
throw ndjsonRecordTooLarge();
|
|
754
845
|
}
|
|
846
|
+
pending.push(rest);
|
|
755
847
|
}
|
|
756
848
|
}
|
|
757
|
-
|
|
758
|
-
signal
|
|
759
|
-
|
|
760
|
-
|
|
849
|
+
if (signal?.aborted) {
|
|
850
|
+
throw signal.reason ?? new DOMException("aborted", "AbortError");
|
|
851
|
+
}
|
|
852
|
+
const finalLine = decoder
|
|
853
|
+
.decode(concatBytes(pending, pendingBytes, new Uint8Array()))
|
|
854
|
+
.trim();
|
|
855
|
+
if (finalLine) {
|
|
856
|
+
yield JSON.parse(finalLine);
|
|
761
857
|
}
|
|
762
858
|
}
|
|
859
|
+
function ndjsonRecordTooLarge() {
|
|
860
|
+
return new LeaniError(`NDJSON record exceeds ${MAX_NDJSON_RECORD_BYTES} bytes`, { status: 200, code: "invalid_response", retryable: false });
|
|
861
|
+
}
|
|
763
862
|
function normalizeBaseUrl(value) {
|
|
764
863
|
const url = new URL(value);
|
|
765
864
|
if (!url.pathname.endsWith("/")) {
|
|
@@ -783,6 +882,11 @@ function requestHeaders(token, accept, credential, sessionToken) {
|
|
|
783
882
|
}
|
|
784
883
|
return headers;
|
|
785
884
|
}
|
|
885
|
+
/** Mark a mutation without a JSON body, which the node otherwise refuses. */
|
|
886
|
+
function markLeaniRequest(headers) {
|
|
887
|
+
headers.set("x-leani-request", "1");
|
|
888
|
+
return headers;
|
|
889
|
+
}
|
|
786
890
|
function consumerPath(subscription, consumer, operation) {
|
|
787
891
|
return (`v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}` +
|
|
788
892
|
`/consumers/${encodeURIComponent(assertIdentity(consumer, "consumer"))}/${operation}`);
|