@smart-byte/leani-sdk 0.1.0-rc.1 → 0.1.0-rc.2
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 +21 -7
- package/dist/backfill.d.ts +15 -2
- package/dist/backfill.js +277 -190
- 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 +39 -5
- package/dist/index.js +177 -150
- package/dist/index.js.map +1 -1
- package/dist/openapi.generated.d.ts +532 -88
- 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,
|
|
@@ -53,18 +54,19 @@ export function createBackfillSubscriptionClient(options) {
|
|
|
53
54
|
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
54
55
|
assertPositiveInteger(timeoutMs, "timeoutMs");
|
|
55
56
|
const requestJson = async (method, path, body, signal, credential, sessionToken) => {
|
|
56
|
-
const
|
|
57
|
+
const headers = requestHeaders(options.token, "application/json", credential, sessionToken);
|
|
58
|
+
const response = await send(fetchImpl, resolveUnder(baseUrl, path), {
|
|
57
59
|
method,
|
|
58
|
-
headers:
|
|
60
|
+
headers: method === "GET" || body !== undefined
|
|
61
|
+
? headers
|
|
62
|
+
: markLeaniRequest(headers),
|
|
59
63
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
60
|
-
signal: signal
|
|
61
|
-
|
|
62
|
-
: AbortSignal.timeout(timeoutMs),
|
|
63
|
-
});
|
|
64
|
+
signal: withDeadline(signal, timeoutMs),
|
|
65
|
+
}, signal);
|
|
64
66
|
if (!response.ok) {
|
|
65
67
|
throw await responseError(response);
|
|
66
68
|
}
|
|
67
|
-
return (
|
|
69
|
+
return readJson(response, signal);
|
|
68
70
|
};
|
|
69
71
|
return Object.freeze({
|
|
70
72
|
baseUrl,
|
|
@@ -130,26 +132,17 @@ export function createBackfillSubscriptionClient(options) {
|
|
|
130
132
|
delete: (subscription, deleteOptions) => requestJson("DELETE", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}`, undefined, deleteOptions?.signal),
|
|
131
133
|
changes: (subscription, consumer, changeOptions = {}) => {
|
|
132
134
|
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
|
-
});
|
|
135
|
+
const query = changeOptions.limit === undefined
|
|
136
|
+
? ""
|
|
137
|
+
: `?limit=${assertPositiveInteger(changeOptions.limit, "limit")}`;
|
|
138
|
+
return requestJson("GET", `${path}${query}`, undefined, changeOptions.signal, changeOptions.credential);
|
|
146
139
|
},
|
|
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),
|
|
140
|
+
stream: (subscription, consumer, streamOptions = {}) => openBackfillStream(fetchImpl, baseUrl, options.token, timeoutMs, subscription, consumer, streamOptions.credential, streamOptions.batching, streamOptions.signal),
|
|
141
|
+
streamLive: (processor, consumer, streamOptions = {}) => openLiveStream(fetchImpl, baseUrl, options.token, timeoutMs, processor, consumer, streamOptions.credential, streamOptions.signal),
|
|
142
|
+
subscribe: (request) => openMultiLaneDelivery(fetchImpl, baseUrl, options.token, timeoutMs, request),
|
|
150
143
|
});
|
|
151
144
|
}
|
|
152
|
-
async function openMultiLaneDelivery(fetchImpl, baseUrl, token, request) {
|
|
145
|
+
async function openMultiLaneDelivery(fetchImpl, baseUrl, token, timeoutMs, request) {
|
|
153
146
|
const processor = assertIdentity(request.processor, "processor");
|
|
154
147
|
const consumer = assertIdentity(request.consumer, "consumer");
|
|
155
148
|
const history = request.lanes.history ?? [];
|
|
@@ -164,11 +157,11 @@ async function openMultiLaneDelivery(fetchImpl, baseUrl, token, request) {
|
|
|
164
157
|
const lanes = [];
|
|
165
158
|
try {
|
|
166
159
|
if (request.lanes.live) {
|
|
167
|
-
const open = (signal = request.signal) => openLiveStream(fetchImpl, baseUrl, token, processor, consumer, request.credential, signal);
|
|
160
|
+
const open = (signal = request.signal) => openLiveStream(fetchImpl, baseUrl, token, timeoutMs, processor, consumer, request.credential, signal);
|
|
168
161
|
lanes.push(unifiedLiveLane(await open(), open, request.signal));
|
|
169
162
|
}
|
|
170
163
|
for (const subscriptionId of historyIds) {
|
|
171
|
-
const open = (signal = request.signal) => openBackfillStream(fetchImpl, baseUrl, token, subscriptionId, consumer, request.credential, request.batching, signal);
|
|
164
|
+
const open = (signal = request.signal) => openBackfillStream(fetchImpl, baseUrl, token, timeoutMs, subscriptionId, consumer, request.credential, request.batching, signal);
|
|
172
165
|
lanes.push(unifiedHistoryLane(subscriptionId, await open(), open, request.signal));
|
|
173
166
|
}
|
|
174
167
|
}
|
|
@@ -289,33 +282,10 @@ function unifiedHistoryLane(subscriptionId, initialSession, open, signal) {
|
|
|
289
282
|
return { done: true, value: undefined };
|
|
290
283
|
if (next.value.type === "heartbeat")
|
|
291
284
|
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;
|
|
285
|
+
complete = next.value.type === "backfill_complete";
|
|
309
286
|
return {
|
|
310
287
|
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
|
-
},
|
|
288
|
+
value: historyDeliveryBatch(subscriptionId, next.value),
|
|
319
289
|
};
|
|
320
290
|
}
|
|
321
291
|
},
|
|
@@ -323,6 +293,28 @@ function unifiedHistoryLane(subscriptionId, initialSession, open, signal) {
|
|
|
323
293
|
close: connection.close,
|
|
324
294
|
};
|
|
325
295
|
}
|
|
296
|
+
/** A history batch or the completion with the cursor that acknowledges it. */
|
|
297
|
+
function historyDeliveryBatch(laneId, record) {
|
|
298
|
+
if (record.type === "backfill_complete") {
|
|
299
|
+
return {
|
|
300
|
+
laneKind: "history",
|
|
301
|
+
laneId,
|
|
302
|
+
streamId: record.streamId,
|
|
303
|
+
ackCursor: record.cursor,
|
|
304
|
+
events: [],
|
|
305
|
+
completion: record,
|
|
306
|
+
record,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
laneKind: "history",
|
|
311
|
+
laneId,
|
|
312
|
+
streamId: record.streamId,
|
|
313
|
+
ackCursor: record.acknowledgeableCursor,
|
|
314
|
+
events: record.changes,
|
|
315
|
+
record,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
326
318
|
function reconnectingLaneConnection(initialSession, open, signal) {
|
|
327
319
|
const expected = initialSession.hello;
|
|
328
320
|
const stop = new AbortController();
|
|
@@ -399,8 +391,10 @@ function reconnectingLaneConnection(initialSession, open, signal) {
|
|
|
399
391
|
closed = true;
|
|
400
392
|
stop.abort();
|
|
401
393
|
await session.close();
|
|
394
|
+
// A stopped reconnect rejects with the abort reason, which may be the
|
|
395
|
+
// caller's own, of any type.
|
|
402
396
|
await reconnectTask?.catch((error) => {
|
|
403
|
-
if (!
|
|
397
|
+
if (!reconnectSignal.aborted)
|
|
404
398
|
throw error;
|
|
405
399
|
});
|
|
406
400
|
},
|
|
@@ -418,26 +412,16 @@ function validateReplacementHello(expected, replacement) {
|
|
|
418
412
|
throw streamProtocolError("live", "replacement connection changed its durable lane identity");
|
|
419
413
|
}
|
|
420
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* Classified by where the failure came from, never by its message: the SDK
|
|
417
|
+
* reports every failure below HTTP as a retryable TransportError, and a
|
|
418
|
+
* caller's abort, a stream record, or a local check is never retried.
|
|
419
|
+
*/
|
|
421
420
|
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";
|
|
421
|
+
return (error instanceof LeaniError &&
|
|
422
|
+
(error.retryable ||
|
|
423
|
+
error.code === "consumer_session_active" ||
|
|
424
|
+
error.code === "consumer_session_lost"));
|
|
441
425
|
}
|
|
442
426
|
async function waitForReconnectDelay(delayMs, signal) {
|
|
443
427
|
if (signal.aborted) {
|
|
@@ -511,8 +495,8 @@ function iterateUnifiedLanes(lanes, claim) {
|
|
|
511
495
|
},
|
|
512
496
|
};
|
|
513
497
|
}
|
|
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");
|
|
498
|
+
async function openBackfillStream(fetchImpl, baseUrl, token, timeoutMs, subscription, consumer, credential, batching, signal) {
|
|
499
|
+
const session = await openDeliverySession(fetchImpl, baseUrl, token, timeoutMs, backfillStreamPath(subscription, consumer, batching), consumerPath(subscription, consumer, "ack"), consumerPath(subscription, consumer, "lease"), credential, signal, "backfill");
|
|
516
500
|
const batches = async function* () {
|
|
517
501
|
for await (const record of session) {
|
|
518
502
|
if (record.type === "batch") {
|
|
@@ -520,6 +504,15 @@ async function openBackfillStream(fetchImpl, baseUrl, token, subscription, consu
|
|
|
520
504
|
}
|
|
521
505
|
}
|
|
522
506
|
};
|
|
507
|
+
// The stream ends once the completion is acknowledged, so iteration
|
|
508
|
+
// continues past it rather than closing the session first.
|
|
509
|
+
const deliveryBatches = async function* () {
|
|
510
|
+
for await (const record of session) {
|
|
511
|
+
if (record.type !== "heartbeat") {
|
|
512
|
+
yield historyDeliveryBatch(session.hello.subscriptionId, record);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
};
|
|
523
516
|
const events = async function* () {
|
|
524
517
|
for await (const batch of batches()) {
|
|
525
518
|
yield* batch.changes;
|
|
@@ -530,90 +523,119 @@ async function openBackfillStream(fetchImpl, baseUrl, token, subscription, consu
|
|
|
530
523
|
acknowledge: session.acknowledge.bind(session),
|
|
531
524
|
close: session.close.bind(session),
|
|
532
525
|
batches,
|
|
526
|
+
deliveryBatches,
|
|
533
527
|
events,
|
|
534
528
|
[Symbol.asyncIterator]: session[Symbol.asyncIterator].bind(session),
|
|
535
529
|
});
|
|
536
530
|
}
|
|
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");
|
|
531
|
+
async function openLiveStream(fetchImpl, baseUrl, token, timeoutMs, processor, consumer, credential, signal) {
|
|
532
|
+
return openDeliverySession(fetchImpl, baseUrl, token, timeoutMs, liveConsumerPath(processor, consumer, "stream"), liveConsumerPath(processor, consumer, "ack"), liveConsumerPath(processor, consumer, "lease"), credential, signal, "live");
|
|
539
533
|
}
|
|
540
|
-
async function openDeliverySession(fetchImpl, baseUrl, token, streamPath, acknowledgePath, renewPath, credential, signal, streamKind) {
|
|
541
|
-
const
|
|
542
|
-
|
|
534
|
+
async function openDeliverySession(fetchImpl, baseUrl, token, timeoutMs, streamPath, acknowledgePath, renewPath, credential, signal, streamKind) {
|
|
535
|
+
const connection = new AbortController();
|
|
536
|
+
// A connection that fails on its own, rather than through the caller's
|
|
537
|
+
// abort or close(), keeps the failure for the iterator and acknowledge().
|
|
538
|
+
let failure;
|
|
539
|
+
const fail = (error) => {
|
|
540
|
+
if (connection.signal.aborted) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
failure = error;
|
|
544
|
+
connection.abort(error);
|
|
545
|
+
};
|
|
546
|
+
const stopFromCaller = () => connection.abort(signal?.reason);
|
|
547
|
+
signal?.addEventListener("abort", stopFromCaller, { once: true });
|
|
543
548
|
if (signal?.aborted) {
|
|
544
549
|
stopFromCaller();
|
|
545
550
|
}
|
|
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
|
-
|
|
551
|
+
// Until its hello arrives, the stream may stay silent as long as a JSON
|
|
552
|
+
// request may take; afterwards, for three heartbeat intervals.
|
|
553
|
+
let watchdog = idleWatchdog(timeoutMs, () => fail(new TransportError(`${streamKind} stream sent no hello within ${timeoutMs} ms`)));
|
|
554
|
+
let records;
|
|
555
|
+
let wireHello;
|
|
556
|
+
let sessionToken;
|
|
557
|
+
let leaseTtlMs;
|
|
558
|
+
let heartbeatIntervalMs;
|
|
559
|
+
try {
|
|
560
|
+
watchdog.arm();
|
|
561
|
+
const response = await send(fetchImpl, resolveUnder(baseUrl, streamPath), {
|
|
562
|
+
headers: requestHeaders(token, "application/x-ndjson", credential),
|
|
563
|
+
signal: connection.signal,
|
|
564
|
+
}, signal);
|
|
565
|
+
if (!response.ok) {
|
|
566
|
+
throw await responseError(response);
|
|
567
|
+
}
|
|
568
|
+
if (!response.body) {
|
|
569
|
+
throw new BackfillStreamError({
|
|
570
|
+
type: "error",
|
|
571
|
+
code: "stream_body_missing",
|
|
572
|
+
message: `${streamKind} stream response has no body`,
|
|
573
|
+
earliestAvailableSequence: null,
|
|
574
|
+
latestAvailableSequence: null,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
records = ndjsonRecords(response.body, connection.signal, () => watchdog.arm());
|
|
578
|
+
const first = await records.next();
|
|
579
|
+
if (first.done) {
|
|
580
|
+
throw streamProtocolError(streamKind, "stream ended before its hello record");
|
|
581
|
+
}
|
|
582
|
+
const firstType = first.value.type;
|
|
583
|
+
if (firstType === "error" || firstType === "reset_required") {
|
|
584
|
+
throw new BackfillStreamError(first.value);
|
|
585
|
+
}
|
|
586
|
+
if (firstType !== "hello") {
|
|
587
|
+
throw streamProtocolError(streamKind, "first stream record is not hello");
|
|
588
|
+
}
|
|
589
|
+
wireHello = first.value;
|
|
590
|
+
sessionToken = assertIdentity(wireHello.sessionToken, "stream hello sessionToken");
|
|
591
|
+
leaseTtlMs = parseHelloDuration(wireHello.leaseTtlMs, "leaseTtlMs");
|
|
592
|
+
heartbeatIntervalMs = parseHelloDuration(wireHello.heartbeatIntervalMs, "heartbeatIntervalMs");
|
|
573
593
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
connectionStop.abort();
|
|
594
|
+
catch (error) {
|
|
595
|
+
connection.abort();
|
|
577
596
|
signal?.removeEventListener("abort", stopFromCaller);
|
|
578
|
-
|
|
597
|
+
await records?.return(undefined).catch(() => undefined);
|
|
598
|
+
throw failure ?? error;
|
|
579
599
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
signal?.removeEventListener("abort", stopFromCaller);
|
|
583
|
-
throw streamProtocolError(streamKind, "first stream record is not hello");
|
|
600
|
+
finally {
|
|
601
|
+
watchdog.disarm();
|
|
584
602
|
}
|
|
585
|
-
const
|
|
586
|
-
const sessionToken = assertIdentity(wireHello.sessionToken, "stream hello sessionToken");
|
|
587
|
-
const leaseTtlMs = parseLeaseTtl(wireHello.leaseTtlMs);
|
|
603
|
+
const stream = records;
|
|
588
604
|
const { sessionToken: _sessionToken, ...publicHello } = wireHello;
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
605
|
+
const idleMs = 3 * heartbeatIntervalMs;
|
|
606
|
+
watchdog = idleWatchdog(idleMs, () => fail(new TransportError(`${streamKind} stream sent nothing for ${idleMs} ms`)));
|
|
607
|
+
const renewal = new AbortController();
|
|
608
|
+
const renewalTask = renewSessionUntilStopped(fetchImpl, baseUrl, token, timeoutMs, renewPath, credential, sessionToken, leaseTtlMs, renewal.signal).catch((error) => {
|
|
609
|
+
// A lease that cannot be renewed ends the connection at once instead of
|
|
610
|
+
// at the next record, which may never come.
|
|
611
|
+
if (!renewal.signal.aborted) {
|
|
612
|
+
fail(error);
|
|
594
613
|
}
|
|
595
614
|
});
|
|
596
615
|
let iteratorClaimed = false;
|
|
597
616
|
let closeTask;
|
|
598
617
|
const close = () => {
|
|
599
618
|
closeTask ??= (async () => {
|
|
600
|
-
|
|
601
|
-
|
|
619
|
+
renewal.abort();
|
|
620
|
+
watchdog.disarm();
|
|
621
|
+
connection.abort();
|
|
602
622
|
signal?.removeEventListener("abort", stopFromCaller);
|
|
603
623
|
try {
|
|
604
|
-
await
|
|
624
|
+
await stream.return(undefined);
|
|
605
625
|
}
|
|
606
626
|
catch (error) {
|
|
607
|
-
if (!
|
|
627
|
+
if (!connection.signal.aborted) {
|
|
608
628
|
throw error;
|
|
609
629
|
}
|
|
610
630
|
}
|
|
611
631
|
await renewalTask;
|
|
612
632
|
try {
|
|
613
|
-
await fetchImpl
|
|
633
|
+
const released = await send(fetchImpl, resolveUnder(baseUrl, renewPath), {
|
|
614
634
|
method: "DELETE",
|
|
615
|
-
headers: requestHeaders(token, "application/json", credential, sessionToken),
|
|
616
|
-
|
|
635
|
+
headers: markLeaniRequest(requestHeaders(token, "application/json", credential, sessionToken)),
|
|
636
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
637
|
+
}, undefined);
|
|
638
|
+
void released.body?.cancel().catch(() => undefined);
|
|
617
639
|
}
|
|
618
640
|
catch {
|
|
619
641
|
// The lease also expires and is passively released when the stream
|
|
@@ -629,14 +651,27 @@ async function openDeliverySession(fetchImpl, baseUrl, token, streamPath, acknow
|
|
|
629
651
|
iteratorClaimed = true;
|
|
630
652
|
try {
|
|
631
653
|
while (true) {
|
|
632
|
-
|
|
633
|
-
|
|
654
|
+
let next;
|
|
655
|
+
// Only a pending read counts silence, not the time the application
|
|
656
|
+
// spends on a record.
|
|
657
|
+
watchdog.arm();
|
|
658
|
+
try {
|
|
659
|
+
next = await stream.next();
|
|
634
660
|
}
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
if (
|
|
638
|
-
|
|
661
|
+
catch (error) {
|
|
662
|
+
// The caller's abort and close() end the iteration quietly.
|
|
663
|
+
if (failure === undefined && connection.signal.aborted) {
|
|
664
|
+
return;
|
|
639
665
|
}
|
|
666
|
+
throw failure ?? error;
|
|
667
|
+
}
|
|
668
|
+
finally {
|
|
669
|
+
watchdog.disarm();
|
|
670
|
+
}
|
|
671
|
+
if (failure !== undefined) {
|
|
672
|
+
throw failure;
|
|
673
|
+
}
|
|
674
|
+
if (next.done) {
|
|
640
675
|
return;
|
|
641
676
|
}
|
|
642
677
|
const record = next.value;
|
|
@@ -660,19 +695,20 @@ async function openDeliverySession(fetchImpl, baseUrl, token, streamPath, acknow
|
|
|
660
695
|
if (closeTask !== undefined) {
|
|
661
696
|
throw new TypeError("delivery session is closed");
|
|
662
697
|
}
|
|
663
|
-
if (
|
|
664
|
-
throw
|
|
698
|
+
if (failure !== undefined) {
|
|
699
|
+
throw failure;
|
|
665
700
|
}
|
|
666
|
-
const
|
|
701
|
+
const caller = acknowledgeOptions?.signal;
|
|
702
|
+
const acknowledge = await send(fetchImpl, resolveUnder(baseUrl, acknowledgePath), {
|
|
667
703
|
method: "POST",
|
|
668
704
|
headers: requestHeaders(token, "application/json", credential, sessionToken),
|
|
669
705
|
body: JSON.stringify({ cursor: assertIdentity(cursor, "cursor") }),
|
|
670
|
-
signal:
|
|
671
|
-
});
|
|
706
|
+
signal: withDeadline(caller, timeoutMs),
|
|
707
|
+
}, caller);
|
|
672
708
|
if (!acknowledge.ok) {
|
|
673
709
|
throw await responseError(acknowledge);
|
|
674
710
|
}
|
|
675
|
-
return (
|
|
711
|
+
return readJson(acknowledge, caller);
|
|
676
712
|
},
|
|
677
713
|
close,
|
|
678
714
|
[Symbol.asyncIterator]: iterate,
|
|
@@ -687,79 +723,125 @@ function streamProtocolError(streamKind, message) {
|
|
|
687
723
|
latestAvailableSequence: null,
|
|
688
724
|
});
|
|
689
725
|
}
|
|
690
|
-
async function renewSessionUntilStopped(fetchImpl, baseUrl, token, renewPath, credential, sessionToken, leaseTtlMs, stop) {
|
|
726
|
+
async function renewSessionUntilStopped(fetchImpl, baseUrl, token, timeoutMs, renewPath, credential, sessionToken, leaseTtlMs, stop) {
|
|
691
727
|
const intervalMs = Math.max(50, Math.floor(leaseTtlMs / 3));
|
|
728
|
+
const firstRetryMs = Math.max(10, Math.floor(intervalMs / 10));
|
|
729
|
+
// The node renewed the lease no earlier than the request that renewed it.
|
|
730
|
+
let renewedAt = performance.now();
|
|
731
|
+
let delayMs = intervalMs;
|
|
732
|
+
let retryMs = firstRetryMs;
|
|
692
733
|
while (!stop.aborted) {
|
|
693
|
-
await abortableDelay(
|
|
734
|
+
await abortableDelay(delayMs, stop);
|
|
694
735
|
if (stop.aborted) {
|
|
695
736
|
return;
|
|
696
737
|
}
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
738
|
+
const attemptedAt = performance.now();
|
|
739
|
+
const leaseLeftMs = renewedAt + leaseTtlMs - attemptedAt;
|
|
740
|
+
try {
|
|
741
|
+
const response = await send(fetchImpl, resolveUnder(baseUrl, renewPath), {
|
|
742
|
+
method: "POST",
|
|
743
|
+
headers: markLeaniRequest(requestHeaders(token, "application/json", credential, sessionToken)),
|
|
744
|
+
// An answer after the lease lapsed could not keep it.
|
|
745
|
+
signal: withDeadline(stop, Math.max(1, Math.min(timeoutMs, Math.floor(leaseLeftMs)))),
|
|
746
|
+
}, stop);
|
|
747
|
+
if (!response.ok) {
|
|
748
|
+
throw await responseError(response);
|
|
749
|
+
}
|
|
750
|
+
await readText(response, stop);
|
|
751
|
+
renewedAt = attemptedAt;
|
|
752
|
+
delayMs = intervalMs;
|
|
753
|
+
retryMs = firstRetryMs;
|
|
754
|
+
}
|
|
755
|
+
catch (error) {
|
|
756
|
+
// Retry a transient failure, with backoff, while the lease holds.
|
|
757
|
+
const retryable = error instanceof LeaniError && error.retryable;
|
|
758
|
+
if (stop.aborted ||
|
|
759
|
+
!retryable ||
|
|
760
|
+
renewedAt + leaseTtlMs - performance.now() <= retryMs) {
|
|
761
|
+
throw error;
|
|
762
|
+
}
|
|
763
|
+
delayMs = retryMs;
|
|
764
|
+
retryMs *= 2;
|
|
704
765
|
}
|
|
705
|
-
await response.arrayBuffer();
|
|
706
766
|
}
|
|
707
767
|
}
|
|
708
768
|
function abortableDelay(delayMs, signal) {
|
|
709
769
|
return new Promise((resolve) => {
|
|
710
|
-
const
|
|
711
|
-
signal.addEventListener("abort", () => {
|
|
770
|
+
const finish = () => {
|
|
712
771
|
clearTimeout(timeout);
|
|
772
|
+
signal.removeEventListener("abort", finish);
|
|
713
773
|
resolve();
|
|
714
|
-
}
|
|
774
|
+
};
|
|
775
|
+
const timeout = setTimeout(finish, delayMs);
|
|
776
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
715
777
|
});
|
|
716
778
|
}
|
|
717
|
-
function
|
|
718
|
-
const
|
|
719
|
-
if (!Number.isSafeInteger(
|
|
720
|
-
throw new TypeError(
|
|
779
|
+
function parseHelloDuration(value, field) {
|
|
780
|
+
const milliseconds = Number(value);
|
|
781
|
+
if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) {
|
|
782
|
+
throw new TypeError(`stream hello contains an invalid ${field}`);
|
|
721
783
|
}
|
|
722
|
-
return
|
|
784
|
+
return milliseconds;
|
|
723
785
|
}
|
|
724
786
|
export async function* parseNdjson(body, signal) {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
787
|
+
yield* ndjsonRecords(body, signal);
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* The largest NDJSON record the client buffers: the node's default buffer
|
|
791
|
+
* for a batch's changes, 64 MiB, and room for the batch's own fields.
|
|
792
|
+
*/
|
|
793
|
+
const MAX_NDJSON_RECORD_BYTES = 65 * 1024 * 1024;
|
|
794
|
+
const LINE_FEED = 0x0a;
|
|
795
|
+
/**
|
|
796
|
+
* Split NDJSON records at line feeds, scanning each byte once. A line feed is
|
|
797
|
+
* ASCII, so a scan by byte never splits a UTF-8 character.
|
|
798
|
+
*/
|
|
799
|
+
async function* ndjsonRecords(body, signal, onChunk) {
|
|
730
800
|
const decoder = new TextDecoder();
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
801
|
+
// The bytes received so far of the record in progress.
|
|
802
|
+
let pending = [];
|
|
803
|
+
let pendingBytes = 0;
|
|
804
|
+
for await (const chunk of readChunks(body, signal, onChunk)) {
|
|
805
|
+
let lineStart = 0;
|
|
806
|
+
let newline = chunk.indexOf(LINE_FEED);
|
|
807
|
+
while (newline >= 0) {
|
|
808
|
+
const tail = chunk.subarray(lineStart, newline);
|
|
809
|
+
if (pendingBytes + tail.length > MAX_NDJSON_RECORD_BYTES) {
|
|
810
|
+
throw ndjsonRecordTooLarge();
|
|
736
811
|
}
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
}
|
|
746
|
-
newline = buffer.indexOf("\n");
|
|
812
|
+
const line = decoder
|
|
813
|
+
.decode(concatBytes(pending, pendingBytes, tail))
|
|
814
|
+
.trim();
|
|
815
|
+
pending = [];
|
|
816
|
+
pendingBytes = 0;
|
|
817
|
+
lineStart = newline + 1;
|
|
818
|
+
if (line) {
|
|
819
|
+
yield JSON.parse(line);
|
|
747
820
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
821
|
+
newline = chunk.indexOf(LINE_FEED, lineStart);
|
|
822
|
+
}
|
|
823
|
+
const rest = chunk.subarray(lineStart);
|
|
824
|
+
if (rest.length > 0) {
|
|
825
|
+
pendingBytes += rest.length;
|
|
826
|
+
if (pendingBytes > MAX_NDJSON_RECORD_BYTES) {
|
|
827
|
+
throw ndjsonRecordTooLarge();
|
|
754
828
|
}
|
|
829
|
+
pending.push(rest);
|
|
755
830
|
}
|
|
756
831
|
}
|
|
757
|
-
|
|
758
|
-
signal
|
|
759
|
-
|
|
760
|
-
|
|
832
|
+
if (signal?.aborted) {
|
|
833
|
+
throw signal.reason ?? new DOMException("aborted", "AbortError");
|
|
834
|
+
}
|
|
835
|
+
const finalLine = decoder
|
|
836
|
+
.decode(concatBytes(pending, pendingBytes, new Uint8Array()))
|
|
837
|
+
.trim();
|
|
838
|
+
if (finalLine) {
|
|
839
|
+
yield JSON.parse(finalLine);
|
|
761
840
|
}
|
|
762
841
|
}
|
|
842
|
+
function ndjsonRecordTooLarge() {
|
|
843
|
+
return new LeaniError(`NDJSON record exceeds ${MAX_NDJSON_RECORD_BYTES} bytes`, { status: 200, code: "invalid_response", retryable: false });
|
|
844
|
+
}
|
|
763
845
|
function normalizeBaseUrl(value) {
|
|
764
846
|
const url = new URL(value);
|
|
765
847
|
if (!url.pathname.endsWith("/")) {
|
|
@@ -783,6 +865,11 @@ function requestHeaders(token, accept, credential, sessionToken) {
|
|
|
783
865
|
}
|
|
784
866
|
return headers;
|
|
785
867
|
}
|
|
868
|
+
/** Mark a mutation without a JSON body, which the node otherwise refuses. */
|
|
869
|
+
function markLeaniRequest(headers) {
|
|
870
|
+
headers.set("x-leani-request", "1");
|
|
871
|
+
return headers;
|
|
872
|
+
}
|
|
786
873
|
function consumerPath(subscription, consumer, operation) {
|
|
787
874
|
return (`v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}` +
|
|
788
875
|
`/consumers/${encodeURIComponent(assertIdentity(consumer, "consumer"))}/${operation}`);
|