@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 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
@@ -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.aborted) {
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.removeEventListener("abort", abort);
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.removeEventListener("abort", abort);
456
- reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
611
+ signal?.removeEventListener("abort", abort);
612
+ reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
457
613
  };
458
- signal.addEventListener("abort", abort, { once: true });
614
+ signal?.addEventListener("abort", abort, { once: true });
459
615
  });
460
616
  }
461
617
  function iterateUnifiedLanes(lanes, claim) {