@smart-byte/leani-sdk 0.1.0-rc.3 → 0.1.0-rc.5
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 +4 -0
- package/dist/backfill.d.ts +49 -0
- package/dist/backfill.js +161 -5
- package/dist/backfill.js.map +1 -1
- package/dist/index.d.ts +11 -6
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/openapi.generated.d.ts +106 -14
- package/dist/openapi.generated.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -58,6 +58,10 @@ Destination writes must be idempotent because a crash between those steps can
|
|
|
58
58
|
replay a change. Acknowledge a batch's `ackCursor` (or `acknowledgeableCursor`),
|
|
59
59
|
never a change's own cursor; a backfill session's `deliveryBatches()` yields
|
|
60
60
|
every such boundary with its changes, while `events()` yields changes only.
|
|
61
|
+
`runBackfill(client, request, { onBatch })` runs one application-owned
|
|
62
|
+
subscription to its end: it creates it, applies and acknowledges each batch,
|
|
63
|
+
and deletes it once complete. After a crash or an abort, the identical request
|
|
64
|
+
resumes it.
|
|
61
65
|
|
|
62
66
|
The browser build assumes a same-origin deployment or a reverse proxy that
|
|
63
67
|
adds the application's CORS policy. Leani does not enable cross-origin access
|
package/dist/backfill.d.ts
CHANGED
|
@@ -14,6 +14,16 @@ export interface BackfillStatus {
|
|
|
14
14
|
id: string;
|
|
15
15
|
owner: "subscription";
|
|
16
16
|
processor: string;
|
|
17
|
+
/**
|
|
18
|
+
* Whether the node configures the processor instance `processor` now.
|
|
19
|
+
* Without it, the stream and acknowledgement routes refuse the
|
|
20
|
+
* subscription. That can be temporary; if the instance will not return,
|
|
21
|
+
* cancel the subscription and delete it with `discardUnacknowledged: true`,
|
|
22
|
+
* which discards its unacknowledged records.
|
|
23
|
+
*/
|
|
24
|
+
processorConfigured: boolean;
|
|
25
|
+
/** The required consumer; tell your subscriptions apart by it, not by `id`. */
|
|
26
|
+
consumer: string | null;
|
|
17
27
|
deliveryStreamId?: string;
|
|
18
28
|
publicationRevision?: string;
|
|
19
29
|
fromBlock: number;
|
|
@@ -332,4 +342,43 @@ export interface BackfillSubscriptionClient {
|
|
|
332
342
|
subscribe<T = unknown>(request: MultiLaneSubscriptionRequest): Promise<MultiLaneDelivery<T>>;
|
|
333
343
|
}
|
|
334
344
|
export declare function createBackfillSubscriptionClient(options: BackfillSubscriptionClientOptions): BackfillSubscriptionClient;
|
|
345
|
+
export type BackfillFailurePolicy = "retry" | "discard" | "throw";
|
|
346
|
+
export interface RunBackfillOptions<T = unknown> {
|
|
347
|
+
/** Apply one history batch. It is acknowledged after this resolves. */
|
|
348
|
+
onBatch(batch: HistoryDeliveryBatch<T>): void | Promise<void>;
|
|
349
|
+
/** What a failed subscription does: retry it (default), delete it, or throw. */
|
|
350
|
+
onFailure?: BackfillFailurePolicy;
|
|
351
|
+
/** Retries before "retry" gives up and throws. Default 3. */
|
|
352
|
+
maxRetries?: number;
|
|
353
|
+
/** The consumer's credential; `request.consumer.credential` by default. */
|
|
354
|
+
credential?: string;
|
|
355
|
+
batching?: BackfillBatchingRequest;
|
|
356
|
+
/**
|
|
357
|
+
* Stops the run, as `request.signal` does too. The run rejects with the
|
|
358
|
+
* abort reason and keeps the subscription.
|
|
359
|
+
*/
|
|
360
|
+
signal?: AbortSignal;
|
|
361
|
+
}
|
|
362
|
+
export interface RunBackfillResult {
|
|
363
|
+
outcome: "completed" | "cancelled" | "failed";
|
|
364
|
+
/** The subscription as it ended, before its deletion. */
|
|
365
|
+
subscription: BackfillStatus;
|
|
366
|
+
/** The completion record this run received, if it streamed one. */
|
|
367
|
+
completion?: BackfillStreamCompletion;
|
|
368
|
+
deletion: HistoricalWorkDeletion;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Run an application-owned history subscription to its end: create it,
|
|
372
|
+
* apply and acknowledge each history batch, and delete it once complete.
|
|
373
|
+
* Creation is idempotent by `idempotencyKey`, so running the identical
|
|
374
|
+
* request again, as after a crash, resumes the same subscription after its
|
|
375
|
+
* last acknowledged batch; a batch applied but not acknowledged is delivered
|
|
376
|
+
* again, so `onBatch` must be idempotent. After a lost connection or
|
|
377
|
+
* consumer session, it backs off, reads the subscription's state, and opens
|
|
378
|
+
* the stream again unless the subscription ended meanwhile. A failed
|
|
379
|
+
* subscription follows `onFailure`; a cancelled one is deleted with its
|
|
380
|
+
* unacknowledged records. An `onBatch` error, an abort, or any other failure
|
|
381
|
+
* closes the session and is thrown, leaving the subscription for a rerun.
|
|
382
|
+
*/
|
|
383
|
+
export declare function runBackfill<T = unknown>(client: BackfillSubscriptionClient, request: CreateBackfillSubscription, options: RunBackfillOptions<T>): Promise<RunBackfillResult>;
|
|
335
384
|
export declare function parseNdjson(body: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<unknown>;
|
package/dist/backfill.js
CHANGED
|
@@ -159,6 +159,162 @@ export function createBackfillSubscriptionClient(options) {
|
|
|
159
159
|
subscribe: (request) => openMultiLaneDelivery(fetchImpl, baseUrl, options.token, timeoutMs, request),
|
|
160
160
|
});
|
|
161
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Run an application-owned history subscription to its end: create it,
|
|
164
|
+
* apply and acknowledge each history batch, and delete it once complete.
|
|
165
|
+
* Creation is idempotent by `idempotencyKey`, so running the identical
|
|
166
|
+
* request again, as after a crash, resumes the same subscription after its
|
|
167
|
+
* last acknowledged batch; a batch applied but not acknowledged is delivered
|
|
168
|
+
* again, so `onBatch` must be idempotent. After a lost connection or
|
|
169
|
+
* consumer session, it backs off, reads the subscription's state, and opens
|
|
170
|
+
* the stream again unless the subscription ended meanwhile. A failed
|
|
171
|
+
* subscription follows `onFailure`; a cancelled one is deleted with its
|
|
172
|
+
* unacknowledged records. An `onBatch` error, an abort, or any other failure
|
|
173
|
+
* closes the session and is thrown, leaving the subscription for a rerun.
|
|
174
|
+
*/
|
|
175
|
+
export async function runBackfill(client, request, options) {
|
|
176
|
+
const consumer = request.consumer;
|
|
177
|
+
if (consumer === undefined) {
|
|
178
|
+
throw new TypeError("runBackfill requires request.consumer, the consumer that acknowledges the history");
|
|
179
|
+
}
|
|
180
|
+
const maxRetries = options.maxRetries ?? 3;
|
|
181
|
+
if (!Number.isSafeInteger(maxRetries) || maxRetries < 0) {
|
|
182
|
+
throw new TypeError("maxRetries must be a non-negative safe integer");
|
|
183
|
+
}
|
|
184
|
+
// Either signal stops every request of the run.
|
|
185
|
+
const signal = request.signal && options.signal
|
|
186
|
+
? AbortSignal.any([request.signal, options.signal])
|
|
187
|
+
: (options.signal ?? request.signal);
|
|
188
|
+
let subscription = await client.create({ ...request, signal });
|
|
189
|
+
let completion;
|
|
190
|
+
const finish = async (outcome, discardUnacknowledged) => ({
|
|
191
|
+
outcome,
|
|
192
|
+
subscription,
|
|
193
|
+
completion,
|
|
194
|
+
deletion: await client.delete(subscription.id, {
|
|
195
|
+
signal,
|
|
196
|
+
discardUnacknowledged,
|
|
197
|
+
}),
|
|
198
|
+
});
|
|
199
|
+
let retries = 0;
|
|
200
|
+
let delayMs = 1_000;
|
|
201
|
+
// Whether the subscription may have changed since its status was read.
|
|
202
|
+
let stale = false;
|
|
203
|
+
while (true) {
|
|
204
|
+
const { state } = subscription;
|
|
205
|
+
if (state === "complete_reclaimable" || state === "completed") {
|
|
206
|
+
return finish("completed", false);
|
|
207
|
+
}
|
|
208
|
+
if (state === "cancelled") {
|
|
209
|
+
return finish("cancelled", true);
|
|
210
|
+
}
|
|
211
|
+
if (state === "failed") {
|
|
212
|
+
if (options.onFailure === "discard") {
|
|
213
|
+
return finish("failed", true);
|
|
214
|
+
}
|
|
215
|
+
if (options.onFailure === "throw" || retries >= maxRetries) {
|
|
216
|
+
throw new BackfillStreamError({
|
|
217
|
+
type: "error",
|
|
218
|
+
code: "backfill_failed",
|
|
219
|
+
message: subscription.lastError ?? "the historical subscription failed",
|
|
220
|
+
earliestAvailableSequence: null,
|
|
221
|
+
latestAvailableSequence: null,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
retries += 1;
|
|
225
|
+
subscription = await client.retry(subscription.id, { signal });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
if (stale) {
|
|
230
|
+
subscription = await client.inspect(subscription.id, { signal });
|
|
231
|
+
stale = false;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const session = await client.stream(subscription.id, consumer.id, {
|
|
235
|
+
credential: options.credential ?? consumer.credential,
|
|
236
|
+
batching: options.batching,
|
|
237
|
+
signal,
|
|
238
|
+
});
|
|
239
|
+
delayMs = 1_000;
|
|
240
|
+
await applyHistory(session, options, signal, (received) => {
|
|
241
|
+
completion = received;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (error instanceof ApplicationFailure) {
|
|
246
|
+
throw error.cause;
|
|
247
|
+
}
|
|
248
|
+
const ended = error instanceof BackfillStreamError &&
|
|
249
|
+
(error.code === "backfill_failed" || error.code === "backfill_cancelled");
|
|
250
|
+
if (!ended) {
|
|
251
|
+
if (!isTransientStreamFailure(error)) {
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
await waitForReconnectDelay(delayMs, signal);
|
|
255
|
+
delayMs = Math.min(delayMs * 2, 30_000);
|
|
256
|
+
}
|
|
257
|
+
// The subscription's state decides what follows: it failed or was
|
|
258
|
+
// cancelled, or it may have ended while the stream was down, as when
|
|
259
|
+
// the node applied the completion's acknowledgement but its answer was
|
|
260
|
+
// lost. A stream opened again then would only send heartbeats.
|
|
261
|
+
stale = true;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
subscription = await client.inspect(subscription.id, { signal });
|
|
265
|
+
return finish("completed", false);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** What the application's `onBatch` threw: never a delivery failure to recover from. */
|
|
269
|
+
class ApplicationFailure {
|
|
270
|
+
cause;
|
|
271
|
+
constructor(cause) {
|
|
272
|
+
this.cause = cause;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Apply and acknowledge each batch of a history session, then acknowledge
|
|
277
|
+
* its completion, which `received` gets first. The session is closed however
|
|
278
|
+
* this ends.
|
|
279
|
+
*/
|
|
280
|
+
async function applyHistory(session, options, signal, received) {
|
|
281
|
+
try {
|
|
282
|
+
for await (const batch of session.deliveryBatches()) {
|
|
283
|
+
if (batch.completion === undefined) {
|
|
284
|
+
try {
|
|
285
|
+
await options.onBatch(batch);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
throw new ApplicationFailure(error);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
received(batch.completion);
|
|
293
|
+
}
|
|
294
|
+
await session.acknowledge(batch.ackCursor, { signal });
|
|
295
|
+
if (batch.completion !== undefined) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
await session.close();
|
|
302
|
+
}
|
|
303
|
+
// The caller's abort ends a session quietly.
|
|
304
|
+
signal?.throwIfAborted();
|
|
305
|
+
throw new TransportError("history stream ended before its completion");
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Whether a history stream may be opened again: after a disconnect the SDK
|
|
309
|
+
* reports as retryable, or when the node reports, by HTTP error or stream
|
|
310
|
+
* record, that the consumer's session ended or another connection holds it.
|
|
311
|
+
*/
|
|
312
|
+
function isTransientStreamFailure(error) {
|
|
313
|
+
return (isRetryableDeliveryDisconnect(error) ||
|
|
314
|
+
(error instanceof BackfillStreamError &&
|
|
315
|
+
(error.code === "consumer_session_active" ||
|
|
316
|
+
error.code === "consumer_session_lost")));
|
|
317
|
+
}
|
|
162
318
|
async function openMultiLaneDelivery(fetchImpl, baseUrl, token, timeoutMs, request) {
|
|
163
319
|
const processor = assertIdentity(request.processor, "processor");
|
|
164
320
|
const consumer = assertIdentity(request.consumer, "consumer");
|
|
@@ -441,21 +597,21 @@ function isRetryableDeliveryDisconnect(error) {
|
|
|
441
597
|
error.code === "consumer_session_lost"));
|
|
442
598
|
}
|
|
443
599
|
async function waitForReconnectDelay(delayMs, signal) {
|
|
444
|
-
if (signal
|
|
600
|
+
if (signal?.aborted) {
|
|
445
601
|
throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
446
602
|
}
|
|
447
603
|
await new Promise((resolve, reject) => {
|
|
448
604
|
const finish = () => {
|
|
449
|
-
signal
|
|
605
|
+
signal?.removeEventListener("abort", abort);
|
|
450
606
|
resolve();
|
|
451
607
|
};
|
|
452
608
|
const timeout = setTimeout(finish, delayMs);
|
|
453
609
|
const abort = () => {
|
|
454
610
|
clearTimeout(timeout);
|
|
455
|
-
signal
|
|
456
|
-
reject(signal
|
|
611
|
+
signal?.removeEventListener("abort", abort);
|
|
612
|
+
reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
|
|
457
613
|
};
|
|
458
|
-
signal
|
|
614
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
459
615
|
});
|
|
460
616
|
}
|
|
461
617
|
function iterateUnifiedLanes(lanes, claim) {
|