@syncular/server 0.0.6-202 → 0.0.6-205

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/dist/pull.js CHANGED
@@ -9,6 +9,8 @@ const DEFAULT_MAX_SNAPSHOT_BUNDLE_ROW_FRAME_BYTES = 512 * 1024;
9
9
  const MAX_ADAPTIVE_SNAPSHOT_BUNDLE_ROW_FRAME_BYTES = 4 * 1024 * 1024;
10
10
  const DEFAULT_INLINE_SNAPSHOT_ROW_FRAME_BYTES = 256 * 1024;
11
11
  const EMPTY_SNAPSHOT_ROW_FRAMES = encodeSnapshotRows([]);
12
+ const MAX_PULL_TRANSACTION_RETRIES = 2;
13
+ const PULL_TRANSACTION_RETRY_DELAY_MS = 15;
12
14
  function createPullBootstrapTimings() {
13
15
  return {
14
16
  snapshotQueryMs: 0,
@@ -209,6 +211,16 @@ function sanitizeLimit(value, defaultValue, min, max) {
209
211
  return defaultValue;
210
212
  return Math.max(min, Math.min(max, value));
211
213
  }
214
+ function isSerializablePullError(error) {
215
+ const withCode = error;
216
+ return (withCode.code === '40001' ||
217
+ error.message.toLowerCase().includes('could not serialize access'));
218
+ }
219
+ async function delay(ms) {
220
+ await new Promise((resolve) => {
221
+ setTimeout(resolve, ms);
222
+ });
223
+ }
212
224
  /**
213
225
  * Merge all scope values into a flat ScopeValues for cursor tracking.
214
226
  */
@@ -341,8 +353,6 @@ export async function pull(args) {
341
353
  const limitSnapshotRows = sanitizeLimit(request.limitSnapshotRows, 1000, 1, 20000);
342
354
  const maxSnapshotPages = sanitizeLimit(request.maxSnapshotPages, 4, 1, 50);
343
355
  const dedupeRows = request.dedupeRows === true;
344
- const pendingExternalChunkWrites = [];
345
- const bootstrapTimings = createPullBootstrapTimings();
346
356
  // Resolve effective scopes for each subscription
347
357
  const resolved = await resolveEffectiveScopesForSubscriptions({
348
358
  db,
@@ -351,509 +361,491 @@ export async function pull(args) {
351
361
  handlers: args.handlers,
352
362
  scopeCache: args.scopeCache ?? defaultScopeCache,
353
363
  });
354
- const result = await dialect.executeInTransaction(db, async (trx) => {
355
- await dialect.setRepeatableRead(trx);
356
- const maxCommitSeq = await dialect.readMaxCommitSeq(trx, {
357
- partitionId,
358
- });
359
- const minCommitSeq = await dialect.readMinCommitSeq(trx, {
360
- partitionId,
361
- });
362
- const subResponses = [];
363
- const activeSubscriptions = [];
364
- const nextCursors = [];
365
- // Detect external data changes (synthetic commits from notifyExternalDataChange)
366
- // Compute minimum cursor across all active subscriptions to scope the query.
367
- let minSubCursor = Number.MAX_SAFE_INTEGER;
368
- const activeTables = new Set();
369
- for (const sub of resolved) {
370
- if (sub.status === 'revoked' ||
371
- Object.keys(sub.scopes).length === 0)
372
- continue;
373
- activeTables.add(sub.table);
374
- const cursor = Math.max(-1, sub.cursor ?? -1);
375
- if (cursor >= 0 && cursor < minSubCursor) {
376
- minSubCursor = cursor;
377
- }
378
- }
379
- const maxExternalCommitByTable = minSubCursor < Number.MAX_SAFE_INTEGER && minSubCursor >= 0
380
- ? await readLatestExternalCommitByTable(trx, {
381
- partitionId,
382
- afterCursor: minSubCursor,
383
- tables: Array.from(activeTables),
384
- })
385
- : new Map();
386
- for (const sub of resolved) {
387
- const cursor = Math.max(-1, sub.cursor ?? -1);
388
- // Validate table handler exists (throws if not registered)
389
- if (!args.handlers.byTable.has(sub.table)) {
390
- throw new Error(`Unknown table: ${sub.table}`);
391
- }
392
- if (sub.status === 'revoked' ||
393
- Object.keys(sub.scopes).length === 0) {
394
- subResponses.push({
395
- id: sub.id,
396
- status: 'revoked',
397
- scopes: {},
398
- bootstrap: false,
399
- nextCursor: cursor,
400
- commits: [],
364
+ for (let attemptIndex = 0; attemptIndex < MAX_PULL_TRANSACTION_RETRIES; attemptIndex += 1) {
365
+ const pendingExternalChunkWrites = [];
366
+ const bootstrapTimings = createPullBootstrapTimings();
367
+ try {
368
+ const result = await dialect.executeInTransaction(db, async (trx) => {
369
+ await dialect.setRepeatableRead(trx);
370
+ const maxCommitSeq = await dialect.readMaxCommitSeq(trx, {
371
+ partitionId,
401
372
  });
402
- continue;
403
- }
404
- const effectiveScopes = sub.scopes;
405
- activeSubscriptions.push({ scopes: effectiveScopes });
406
- const latestExternalCommitForTable = maxExternalCommitByTable.get(sub.table);
407
- const needsBootstrap = sub.bootstrapState != null ||
408
- cursor < 0 ||
409
- cursor > maxCommitSeq ||
410
- (minCommitSeq > 0 && cursor < minCommitSeq - 1) ||
411
- (latestExternalCommitForTable !== undefined &&
412
- latestExternalCommitForTable > cursor);
413
- if (needsBootstrap) {
414
- const tables = getServerBootstrapOrderFor(args.handlers, sub.table).map((handler) => handler.table);
415
- const preferInlineBootstrapSnapshot = cursor >= 0 ||
416
- sub.bootstrapState != null ||
417
- (latestExternalCommitForTable !== undefined &&
418
- latestExternalCommitForTable > cursor);
419
- const initState = {
420
- asOfCommitSeq: maxCommitSeq,
421
- tables,
422
- tableIndex: 0,
423
- rowCursor: null,
424
- };
425
- const requestedState = sub.bootstrapState ?? null;
426
- const state = requestedState &&
427
- typeof requestedState.asOfCommitSeq === 'number' &&
428
- Array.isArray(requestedState.tables) &&
429
- typeof requestedState.tableIndex === 'number'
430
- ? requestedState
431
- : initState;
432
- // If the bootstrap state's asOfCommitSeq is no longer catch-up-able, restart bootstrap.
433
- const effectiveState = state.asOfCommitSeq < minCommitSeq - 1 ? initState : state;
434
- const tableName = effectiveState.tables[effectiveState.tableIndex];
435
- // No tables (or ran past the end): treat bootstrap as complete.
436
- if (!tableName) {
437
- subResponses.push({
438
- id: sub.id,
439
- status: 'active',
440
- scopes: effectiveScopes,
441
- bootstrap: true,
442
- bootstrapState: null,
443
- nextCursor: effectiveState.asOfCommitSeq,
444
- commits: [],
445
- snapshots: [],
446
- });
447
- nextCursors.push(effectiveState.asOfCommitSeq);
448
- continue;
373
+ const minCommitSeq = await dialect.readMinCommitSeq(trx, {
374
+ partitionId,
375
+ });
376
+ const subResponses = [];
377
+ const activeSubscriptions = [];
378
+ const nextCursors = [];
379
+ // Detect external data changes (synthetic commits from notifyExternalDataChange)
380
+ // Compute minimum cursor across all active subscriptions to scope the query.
381
+ let minSubCursor = Number.MAX_SAFE_INTEGER;
382
+ const activeTables = new Set();
383
+ for (const sub of resolved) {
384
+ if (sub.status === 'revoked' ||
385
+ Object.keys(sub.scopes).length === 0)
386
+ continue;
387
+ activeTables.add(sub.table);
388
+ const cursor = Math.max(-1, sub.cursor ?? -1);
389
+ if (cursor >= 0 && cursor < minSubCursor) {
390
+ minSubCursor = cursor;
391
+ }
449
392
  }
450
- const snapshots = [];
451
- let nextState = effectiveState;
452
- const cacheKey = `${partitionId}:${await scopesToSnapshotChunkScopeKey(effectiveScopes)}`;
453
- const flushSnapshotBundle = async (bundle) => {
454
- if (bundle.inlineRows) {
455
- snapshots.push({
456
- table: bundle.table,
457
- rows: bundle.inlineRows,
458
- isFirstPage: bundle.isFirstPage,
459
- isLastPage: bundle.isLastPage,
393
+ const maxExternalCommitByTable = minSubCursor < Number.MAX_SAFE_INTEGER && minSubCursor >= 0
394
+ ? await readLatestExternalCommitByTable(trx, {
395
+ partitionId,
396
+ afterCursor: minSubCursor,
397
+ tables: Array.from(activeTables),
398
+ })
399
+ : new Map();
400
+ for (const sub of resolved) {
401
+ const cursor = Math.max(-1, sub.cursor ?? -1);
402
+ // Validate table handler exists (throws if not registered)
403
+ if (!args.handlers.byTable.has(sub.table)) {
404
+ throw new Error(`Unknown table: ${sub.table}`);
405
+ }
406
+ if (sub.status === 'revoked' ||
407
+ Object.keys(sub.scopes).length === 0) {
408
+ subResponses.push({
409
+ id: sub.id,
410
+ status: 'revoked',
411
+ scopes: {},
412
+ bootstrap: false,
413
+ nextCursor: cursor,
414
+ commits: [],
460
415
  });
461
- return;
416
+ continue;
462
417
  }
463
- const nowIso = new Date().toISOString();
464
- const bundleRowLimit = Math.max(1, limitSnapshotRows * bundle.pageCount);
465
- const cacheLookupStartedAt = Date.now();
466
- const cached = await readSnapshotChunkRefByPageKey(trx, {
467
- partitionId,
468
- scopeKey: cacheKey,
469
- scope: bundle.table,
470
- asOfCommitSeq: effectiveState.asOfCommitSeq,
471
- rowCursor: bundle.startCursor,
472
- rowLimit: bundleRowLimit,
473
- encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
474
- compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
475
- nowIso,
476
- });
477
- bootstrapTimings.chunkCacheLookupMs += Math.max(0, Date.now() - cacheLookupStartedAt);
478
- let chunkRef = cached;
479
- if (!chunkRef) {
480
- const expiresAt = new Date(Date.now() + Math.max(1000, bundle.ttlMs)).toISOString();
481
- if (args.chunkStorage) {
482
- const snapshot = {
483
- table: bundle.table,
484
- rows: [],
485
- chunks: [],
486
- isFirstPage: bundle.isFirstPage,
487
- isLastPage: bundle.isLastPage,
488
- };
489
- snapshots.push(snapshot);
490
- pendingExternalChunkWrites.push({
491
- snapshot,
492
- cacheLookup: {
418
+ const effectiveScopes = sub.scopes;
419
+ activeSubscriptions.push({ scopes: effectiveScopes });
420
+ const latestExternalCommitForTable = maxExternalCommitByTable.get(sub.table);
421
+ const needsBootstrap = sub.bootstrapState != null ||
422
+ cursor < 0 ||
423
+ cursor > maxCommitSeq ||
424
+ (minCommitSeq > 0 && cursor < minCommitSeq - 1) ||
425
+ (latestExternalCommitForTable !== undefined &&
426
+ latestExternalCommitForTable > cursor);
427
+ if (needsBootstrap) {
428
+ const tables = getServerBootstrapOrderFor(args.handlers, sub.table).map((handler) => handler.table);
429
+ const preferInlineBootstrapSnapshot = cursor >= 0 ||
430
+ sub.bootstrapState != null ||
431
+ (latestExternalCommitForTable !== undefined &&
432
+ latestExternalCommitForTable > cursor);
433
+ const initState = {
434
+ asOfCommitSeq: maxCommitSeq,
435
+ tables,
436
+ tableIndex: 0,
437
+ rowCursor: null,
438
+ };
439
+ const requestedState = sub.bootstrapState ?? null;
440
+ const state = requestedState &&
441
+ typeof requestedState.asOfCommitSeq === 'number' &&
442
+ Array.isArray(requestedState.tables) &&
443
+ typeof requestedState.tableIndex === 'number'
444
+ ? requestedState
445
+ : initState;
446
+ // If the bootstrap state's asOfCommitSeq is no longer catch-up-able, restart bootstrap.
447
+ const effectiveState = state.asOfCommitSeq < minCommitSeq - 1
448
+ ? initState
449
+ : state;
450
+ const tableName = effectiveState.tables[effectiveState.tableIndex];
451
+ // No tables (or ran past the end): treat bootstrap as complete.
452
+ if (!tableName) {
453
+ subResponses.push({
454
+ id: sub.id,
455
+ status: 'active',
456
+ scopes: effectiveScopes,
457
+ bootstrap: true,
458
+ bootstrapState: null,
459
+ nextCursor: effectiveState.asOfCommitSeq,
460
+ commits: [],
461
+ snapshots: [],
462
+ });
463
+ nextCursors.push(effectiveState.asOfCommitSeq);
464
+ continue;
465
+ }
466
+ const snapshots = [];
467
+ let nextState = effectiveState;
468
+ const cacheKey = `${partitionId}:${await scopesToSnapshotChunkScopeKey(effectiveScopes)}`;
469
+ const flushSnapshotBundle = async (bundle) => {
470
+ if (bundle.inlineRows) {
471
+ snapshots.push({
472
+ table: bundle.table,
473
+ rows: bundle.inlineRows,
474
+ isFirstPage: bundle.isFirstPage,
475
+ isLastPage: bundle.isLastPage,
476
+ });
477
+ return;
478
+ }
479
+ const nowIso = new Date().toISOString();
480
+ const bundleRowLimit = Math.max(1, limitSnapshotRows * bundle.pageCount);
481
+ const cacheLookupStartedAt = Date.now();
482
+ const cached = await readSnapshotChunkRefByPageKey(trx, {
483
+ partitionId,
484
+ scopeKey: cacheKey,
485
+ scope: bundle.table,
486
+ asOfCommitSeq: effectiveState.asOfCommitSeq,
487
+ rowCursor: bundle.startCursor,
488
+ rowLimit: bundleRowLimit,
489
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
490
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
491
+ nowIso,
492
+ });
493
+ bootstrapTimings.chunkCacheLookupMs += Math.max(0, Date.now() - cacheLookupStartedAt);
494
+ let chunkRef = cached;
495
+ if (!chunkRef) {
496
+ const expiresAt = new Date(Date.now() + Math.max(1000, bundle.ttlMs)).toISOString();
497
+ if (args.chunkStorage) {
498
+ const snapshot = {
499
+ table: bundle.table,
500
+ rows: [],
501
+ chunks: [],
502
+ isFirstPage: bundle.isFirstPage,
503
+ isLastPage: bundle.isLastPage,
504
+ };
505
+ snapshots.push(snapshot);
506
+ pendingExternalChunkWrites.push({
507
+ snapshot,
508
+ cacheLookup: {
509
+ partitionId,
510
+ scopeKey: cacheKey,
511
+ scope: bundle.table,
512
+ asOfCommitSeq: effectiveState.asOfCommitSeq,
513
+ rowCursor: bundle.startCursor,
514
+ rowLimit: bundleRowLimit,
515
+ },
516
+ rowFrameParts: [...bundle.rowFrameParts],
517
+ expiresAt,
518
+ });
519
+ return;
520
+ }
521
+ const encodedChunk = await encodeCompressedSnapshotChunk(bundle.rowFrameParts);
522
+ bootstrapTimings.chunkGzipMs += encodedChunk.gzipMs;
523
+ bootstrapTimings.chunkHashMs += encodedChunk.hashMs;
524
+ const chunkId = randomId();
525
+ const chunkPersistStartedAt = Date.now();
526
+ chunkRef = await insertSnapshotChunk(trx, {
527
+ chunkId,
493
528
  partitionId,
494
529
  scopeKey: cacheKey,
495
530
  scope: bundle.table,
496
531
  asOfCommitSeq: effectiveState.asOfCommitSeq,
497
532
  rowCursor: bundle.startCursor,
498
533
  rowLimit: bundleRowLimit,
499
- },
500
- rowFrameParts: [...bundle.rowFrameParts],
501
- expiresAt,
534
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
535
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
536
+ sha256: encodedChunk.sha256,
537
+ body: encodedChunk.body,
538
+ expiresAt,
539
+ });
540
+ bootstrapTimings.chunkPersistMs += Math.max(0, Date.now() - chunkPersistStartedAt);
541
+ }
542
+ snapshots.push({
543
+ table: bundle.table,
544
+ rows: [],
545
+ chunks: [chunkRef],
546
+ isFirstPage: bundle.isFirstPage,
547
+ isLastPage: bundle.isLastPage,
502
548
  });
503
- return;
504
- }
505
- const encodedChunk = await encodeCompressedSnapshotChunk(bundle.rowFrameParts);
506
- bootstrapTimings.chunkGzipMs += encodedChunk.gzipMs;
507
- bootstrapTimings.chunkHashMs += encodedChunk.hashMs;
508
- const chunkId = randomId();
509
- const chunkPersistStartedAt = Date.now();
510
- chunkRef = await insertSnapshotChunk(trx, {
511
- chunkId,
512
- partitionId,
513
- scopeKey: cacheKey,
514
- scope: bundle.table,
515
- asOfCommitSeq: effectiveState.asOfCommitSeq,
516
- rowCursor: bundle.startCursor,
517
- rowLimit: bundleRowLimit,
518
- encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
519
- compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
520
- sha256: encodedChunk.sha256,
521
- body: encodedChunk.body,
522
- expiresAt,
523
- });
524
- bootstrapTimings.chunkPersistMs += Math.max(0, Date.now() - chunkPersistStartedAt);
525
- }
526
- snapshots.push({
527
- table: bundle.table,
528
- rows: [],
529
- chunks: [chunkRef],
530
- isFirstPage: bundle.isFirstPage,
531
- isLastPage: bundle.isLastPage,
532
- });
533
- };
534
- let activeBundle = null;
535
- for (let pageIndex = 0; pageIndex < maxSnapshotPages; pageIndex++) {
536
- if (!nextState)
537
- break;
538
- const nextTableName = nextState.tables[nextState.tableIndex];
539
- if (!nextTableName) {
540
- if (activeBundle) {
549
+ };
550
+ let activeBundle = null;
551
+ for (let pageIndex = 0; pageIndex < maxSnapshotPages; pageIndex++) {
552
+ if (!nextState)
553
+ break;
554
+ const nextTableName = nextState.tables[nextState.tableIndex];
555
+ if (!nextTableName) {
556
+ if (activeBundle) {
557
+ activeBundle.isLastPage = true;
558
+ await flushSnapshotBundle(activeBundle);
559
+ activeBundle = null;
560
+ }
561
+ nextState = null;
562
+ break;
563
+ }
564
+ const tableHandler = args.handlers.byTable.get(nextTableName);
565
+ if (!tableHandler) {
566
+ throw new Error(`Unknown table: ${nextTableName}`);
567
+ }
568
+ if (!activeBundle ||
569
+ activeBundle.table !== nextTableName) {
570
+ if (activeBundle) {
571
+ await flushSnapshotBundle(activeBundle);
572
+ }
573
+ const bundleHeader = EMPTY_SNAPSHOT_ROW_FRAMES;
574
+ activeBundle = {
575
+ table: nextTableName,
576
+ startCursor: nextState.rowCursor,
577
+ isFirstPage: nextState.rowCursor == null,
578
+ isLastPage: false,
579
+ pageCount: 0,
580
+ ttlMs: tableHandler.snapshotChunkTtlMs ??
581
+ 24 * 60 * 60 * 1000,
582
+ rowFrameByteLength: bundleHeader.length,
583
+ rowFrameParts: [bundleHeader],
584
+ inlineRows: null,
585
+ };
586
+ }
587
+ const snapshotQueryStartedAt = Date.now();
588
+ const page = await tableHandler.snapshot({
589
+ db: trx,
590
+ actorId: args.auth.actorId,
591
+ auth: args.auth,
592
+ scopeValues: effectiveScopes,
593
+ cursor: nextState.rowCursor,
594
+ limit: limitSnapshotRows,
595
+ }, sub.params);
596
+ bootstrapTimings.snapshotQueryMs += Math.max(0, Date.now() - snapshotQueryStartedAt);
597
+ const rowFrameEncodeStartedAt = Date.now();
598
+ const rowFrames = encodeSnapshotRowFrames(page.rows ?? []);
599
+ bootstrapTimings.rowFrameEncodeMs += Math.max(0, Date.now() - rowFrameEncodeStartedAt);
600
+ const bundleMaxBytes = resolveSnapshotBundleMaxBytes({
601
+ configuredMaxBytes: tableHandler.snapshotBundleMaxBytes,
602
+ pageRowCount: page.rows?.length ?? 0,
603
+ pageRowFrameBytes: rowFrames.length,
604
+ });
605
+ if (activeBundle.pageCount > 0 &&
606
+ activeBundle.rowFrameByteLength + rowFrames.length >
607
+ bundleMaxBytes) {
608
+ await flushSnapshotBundle(activeBundle);
609
+ const bundleHeader = EMPTY_SNAPSHOT_ROW_FRAMES;
610
+ activeBundle = {
611
+ table: nextTableName,
612
+ startCursor: nextState.rowCursor,
613
+ isFirstPage: nextState.rowCursor == null,
614
+ isLastPage: false,
615
+ pageCount: 0,
616
+ ttlMs: tableHandler.snapshotChunkTtlMs ??
617
+ 24 * 60 * 60 * 1000,
618
+ rowFrameByteLength: bundleHeader.length,
619
+ rowFrameParts: [bundleHeader],
620
+ inlineRows: null,
621
+ };
622
+ }
623
+ if (preferInlineBootstrapSnapshot &&
624
+ activeBundle.pageCount === 0 &&
625
+ page.nextCursor == null &&
626
+ rowFrames.length <=
627
+ DEFAULT_INLINE_SNAPSHOT_ROW_FRAME_BYTES) {
628
+ activeBundle.inlineRows = page.rows ?? [];
629
+ }
630
+ else {
631
+ activeBundle.inlineRows = null;
632
+ }
633
+ activeBundle.rowFrameParts.push(rowFrames);
634
+ activeBundle.rowFrameByteLength += rowFrames.length;
635
+ activeBundle.pageCount += 1;
636
+ if (page.nextCursor != null) {
637
+ nextState = {
638
+ ...nextState,
639
+ rowCursor: page.nextCursor,
640
+ };
641
+ continue;
642
+ }
541
643
  activeBundle.isLastPage = true;
542
644
  await flushSnapshotBundle(activeBundle);
543
645
  activeBundle = null;
646
+ if (nextState.tableIndex + 1 < nextState.tables.length) {
647
+ nextState = {
648
+ ...nextState,
649
+ tableIndex: nextState.tableIndex + 1,
650
+ rowCursor: null,
651
+ };
652
+ continue;
653
+ }
654
+ nextState = null;
655
+ break;
544
656
  }
545
- nextState = null;
546
- break;
547
- }
548
- const tableHandler = args.handlers.byTable.get(nextTableName);
549
- if (!tableHandler) {
550
- throw new Error(`Unknown table: ${nextTableName}`);
551
- }
552
- if (!activeBundle || activeBundle.table !== nextTableName) {
553
657
  if (activeBundle) {
554
658
  await flushSnapshotBundle(activeBundle);
555
659
  }
556
- const bundleHeader = EMPTY_SNAPSHOT_ROW_FRAMES;
557
- activeBundle = {
558
- table: nextTableName,
559
- startCursor: nextState.rowCursor,
560
- isFirstPage: nextState.rowCursor == null,
561
- isLastPage: false,
562
- pageCount: 0,
563
- ttlMs: tableHandler.snapshotChunkTtlMs ?? 24 * 60 * 60 * 1000,
564
- rowFrameByteLength: bundleHeader.length,
565
- rowFrameParts: [bundleHeader],
566
- inlineRows: null,
567
- };
568
- }
569
- const snapshotQueryStartedAt = Date.now();
570
- const page = await tableHandler.snapshot({
571
- db: trx,
572
- actorId: args.auth.actorId,
573
- auth: args.auth,
574
- scopeValues: effectiveScopes,
575
- cursor: nextState.rowCursor,
576
- limit: limitSnapshotRows,
577
- }, sub.params);
578
- bootstrapTimings.snapshotQueryMs += Math.max(0, Date.now() - snapshotQueryStartedAt);
579
- const rowFrameEncodeStartedAt = Date.now();
580
- const rowFrames = encodeSnapshotRowFrames(page.rows ?? []);
581
- bootstrapTimings.rowFrameEncodeMs += Math.max(0, Date.now() - rowFrameEncodeStartedAt);
582
- const bundleMaxBytes = resolveSnapshotBundleMaxBytes({
583
- configuredMaxBytes: tableHandler.snapshotBundleMaxBytes,
584
- pageRowCount: page.rows?.length ?? 0,
585
- pageRowFrameBytes: rowFrames.length,
586
- });
587
- if (activeBundle.pageCount > 0 &&
588
- activeBundle.rowFrameByteLength + rowFrames.length >
589
- bundleMaxBytes) {
590
- await flushSnapshotBundle(activeBundle);
591
- const bundleHeader = EMPTY_SNAPSHOT_ROW_FRAMES;
592
- activeBundle = {
593
- table: nextTableName,
594
- startCursor: nextState.rowCursor,
595
- isFirstPage: nextState.rowCursor == null,
596
- isLastPage: false,
597
- pageCount: 0,
598
- ttlMs: tableHandler.snapshotChunkTtlMs ?? 24 * 60 * 60 * 1000,
599
- rowFrameByteLength: bundleHeader.length,
600
- rowFrameParts: [bundleHeader],
601
- inlineRows: null,
602
- };
660
+ subResponses.push({
661
+ id: sub.id,
662
+ status: 'active',
663
+ scopes: effectiveScopes,
664
+ bootstrap: true,
665
+ bootstrapState: nextState,
666
+ nextCursor: effectiveState.asOfCommitSeq,
667
+ commits: [],
668
+ snapshots,
669
+ });
670
+ nextCursors.push(effectiveState.asOfCommitSeq);
671
+ continue;
603
672
  }
604
- if (preferInlineBootstrapSnapshot &&
605
- activeBundle.pageCount === 0 &&
606
- page.nextCursor == null &&
607
- rowFrames.length <= DEFAULT_INLINE_SNAPSHOT_ROW_FRAME_BYTES) {
608
- activeBundle.inlineRows = page.rows ?? [];
673
+ // Incremental pull for this subscription. The dialect row query
674
+ // carries the scanned commit-window max when matching rows exist,
675
+ // so we only need a separate commit-window scan when the row query
676
+ // returns no matches at all.
677
+ const incrementalRows = [];
678
+ let maxScannedCommitSeq = cursor;
679
+ for await (const row of dialect.iterateIncrementalPullRows(trx, {
680
+ partitionId,
681
+ table: sub.table,
682
+ scopes: effectiveScopes,
683
+ cursor,
684
+ limitCommits,
685
+ })) {
686
+ incrementalRows.push(row);
687
+ maxScannedCommitSeq = Math.max(maxScannedCommitSeq, row.scanned_max_commit_seq ?? row.commit_seq);
609
688
  }
610
- else {
611
- activeBundle.inlineRows = null;
689
+ if (incrementalRows.length === 0) {
690
+ const scannedCommitSeqs = await dialect.readCommitSeqsForPull(trx, {
691
+ partitionId,
692
+ cursor,
693
+ limitCommits,
694
+ tables: [sub.table],
695
+ });
696
+ maxScannedCommitSeq =
697
+ scannedCommitSeqs.length > 0
698
+ ? scannedCommitSeqs[scannedCommitSeqs.length - 1]
699
+ : cursor;
700
+ if (scannedCommitSeqs.length === 0) {
701
+ subResponses.push({
702
+ id: sub.id,
703
+ status: 'active',
704
+ scopes: effectiveScopes,
705
+ bootstrap: false,
706
+ nextCursor: cursor,
707
+ commits: [],
708
+ });
709
+ nextCursors.push(cursor);
710
+ continue;
711
+ }
612
712
  }
613
- activeBundle.rowFrameParts.push(rowFrames);
614
- activeBundle.rowFrameByteLength += rowFrames.length;
615
- activeBundle.pageCount += 1;
616
- if (page.nextCursor != null) {
617
- nextState = { ...nextState, rowCursor: page.nextCursor };
713
+ let nextCursor = cursor;
714
+ if (dedupeRows) {
715
+ const latestByRowKey = new Map();
716
+ for (const r of incrementalRows) {
717
+ nextCursor = Math.max(nextCursor, r.commit_seq);
718
+ const rowKey = `${r.table}\u0000${r.row_id}`;
719
+ const change = {
720
+ table: r.table,
721
+ row_id: r.row_id,
722
+ op: r.op,
723
+ row_json: r.row_json,
724
+ row_version: r.row_version,
725
+ scopes: r.scopes,
726
+ };
727
+ // Move row keys to insertion tail so Map iteration yields
728
+ // "latest change wins" order without a full array sort.
729
+ if (latestByRowKey.has(rowKey)) {
730
+ latestByRowKey.delete(rowKey);
731
+ }
732
+ latestByRowKey.set(rowKey, {
733
+ commitSeq: r.commit_seq,
734
+ createdAt: r.created_at,
735
+ actorId: r.actor_id,
736
+ change,
737
+ });
738
+ }
739
+ nextCursor = Math.max(nextCursor, maxScannedCommitSeq);
740
+ if (latestByRowKey.size === 0) {
741
+ subResponses.push({
742
+ id: sub.id,
743
+ status: 'active',
744
+ scopes: effectiveScopes,
745
+ bootstrap: false,
746
+ nextCursor,
747
+ commits: [],
748
+ });
749
+ nextCursors.push(nextCursor);
750
+ continue;
751
+ }
752
+ const commits = [];
753
+ for (const item of latestByRowKey.values()) {
754
+ const lastCommit = commits[commits.length - 1];
755
+ if (!lastCommit ||
756
+ lastCommit.commitSeq !== item.commitSeq) {
757
+ commits.push({
758
+ commitSeq: item.commitSeq,
759
+ createdAt: item.createdAt,
760
+ actorId: item.actorId,
761
+ changes: [item.change],
762
+ });
763
+ continue;
764
+ }
765
+ lastCommit.changes.push(item.change);
766
+ }
767
+ subResponses.push({
768
+ id: sub.id,
769
+ status: 'active',
770
+ scopes: effectiveScopes,
771
+ bootstrap: false,
772
+ nextCursor,
773
+ commits,
774
+ });
775
+ nextCursors.push(nextCursor);
618
776
  continue;
619
777
  }
620
- activeBundle.isLastPage = true;
621
- await flushSnapshotBundle(activeBundle);
622
- activeBundle = null;
623
- if (nextState.tableIndex + 1 < nextState.tables.length) {
624
- nextState = {
625
- ...nextState,
626
- tableIndex: nextState.tableIndex + 1,
627
- rowCursor: null,
778
+ const commitsBySeq = new Map();
779
+ const commitSeqs = [];
780
+ for (const r of incrementalRows) {
781
+ nextCursor = Math.max(nextCursor, r.commit_seq);
782
+ const seq = r.commit_seq;
783
+ let commit = commitsBySeq.get(seq);
784
+ if (!commit) {
785
+ commit = {
786
+ commitSeq: seq,
787
+ createdAt: r.created_at,
788
+ actorId: r.actor_id,
789
+ changes: [],
790
+ };
791
+ commitsBySeq.set(seq, commit);
792
+ commitSeqs.push(seq);
793
+ }
794
+ const change = {
795
+ table: r.table,
796
+ row_id: r.row_id,
797
+ op: r.op,
798
+ row_json: r.row_json,
799
+ row_version: r.row_version,
800
+ scopes: r.scopes,
628
801
  };
629
- continue;
802
+ commit.changes.push(change);
630
803
  }
631
- nextState = null;
632
- break;
633
- }
634
- if (activeBundle) {
635
- await flushSnapshotBundle(activeBundle);
636
- }
637
- subResponses.push({
638
- id: sub.id,
639
- status: 'active',
640
- scopes: effectiveScopes,
641
- bootstrap: true,
642
- bootstrapState: nextState,
643
- nextCursor: effectiveState.asOfCommitSeq,
644
- commits: [],
645
- snapshots,
646
- });
647
- nextCursors.push(effectiveState.asOfCommitSeq);
648
- continue;
649
- }
650
- // Incremental pull for this subscription
651
- // Read the commit window for this table up-front so the subscription cursor
652
- // can advance past commits that don't match the requested scopes.
653
- const scannedCommitSeqs = await dialect.readCommitSeqsForPull(trx, {
654
- partitionId,
655
- cursor,
656
- limitCommits,
657
- tables: [sub.table],
658
- });
659
- const maxScannedCommitSeq = scannedCommitSeqs.length > 0
660
- ? scannedCommitSeqs[scannedCommitSeqs.length - 1]
661
- : cursor;
662
- if (scannedCommitSeqs.length === 0) {
663
- subResponses.push({
664
- id: sub.id,
665
- status: 'active',
666
- scopes: effectiveScopes,
667
- bootstrap: false,
668
- nextCursor: cursor,
669
- commits: [],
670
- });
671
- nextCursors.push(cursor);
672
- continue;
673
- }
674
- let nextCursor = cursor;
675
- if (dedupeRows) {
676
- const latestByRowKey = new Map();
677
- for await (const r of dialect.iterateIncrementalPullRows(trx, {
678
- partitionId,
679
- table: sub.table,
680
- scopes: effectiveScopes,
681
- cursor,
682
- limitCommits,
683
- })) {
684
- nextCursor = Math.max(nextCursor, r.commit_seq);
685
- const rowKey = `${r.table}\u0000${r.row_id}`;
686
- const change = {
687
- table: r.table,
688
- row_id: r.row_id,
689
- op: r.op,
690
- row_json: r.row_json,
691
- row_version: r.row_version,
692
- scopes: r.scopes,
693
- };
694
- // Move row keys to insertion tail so Map iteration yields
695
- // "latest change wins" order without a full array sort.
696
- if (latestByRowKey.has(rowKey)) {
697
- latestByRowKey.delete(rowKey);
804
+ nextCursor = Math.max(nextCursor, maxScannedCommitSeq);
805
+ if (commitSeqs.length === 0) {
806
+ subResponses.push({
807
+ id: sub.id,
808
+ status: 'active',
809
+ scopes: effectiveScopes,
810
+ bootstrap: false,
811
+ nextCursor,
812
+ commits: [],
813
+ });
814
+ nextCursors.push(nextCursor);
815
+ continue;
698
816
  }
699
- latestByRowKey.set(rowKey, {
700
- commitSeq: r.commit_seq,
701
- createdAt: r.created_at,
702
- actorId: r.actor_id,
703
- change,
704
- });
705
- }
706
- nextCursor = Math.max(nextCursor, maxScannedCommitSeq);
707
- if (latestByRowKey.size === 0) {
817
+ const commits = commitSeqs
818
+ .map((seq) => commitsBySeq.get(seq))
819
+ .filter((c) => !!c)
820
+ .filter((c) => c.changes.length > 0);
708
821
  subResponses.push({
709
822
  id: sub.id,
710
823
  status: 'active',
711
824
  scopes: effectiveScopes,
712
825
  bootstrap: false,
713
826
  nextCursor,
714
- commits: [],
827
+ commits,
715
828
  });
716
829
  nextCursors.push(nextCursor);
717
- continue;
718
830
  }
719
- const commits = [];
720
- for (const item of latestByRowKey.values()) {
721
- const lastCommit = commits[commits.length - 1];
722
- if (!lastCommit || lastCommit.commitSeq !== item.commitSeq) {
723
- commits.push({
724
- commitSeq: item.commitSeq,
725
- createdAt: item.createdAt,
726
- actorId: item.actorId,
727
- changes: [item.change],
728
- });
729
- continue;
730
- }
731
- lastCommit.changes.push(item.change);
732
- }
733
- subResponses.push({
734
- id: sub.id,
735
- status: 'active',
736
- scopes: effectiveScopes,
737
- bootstrap: false,
738
- nextCursor,
739
- commits,
740
- });
741
- nextCursors.push(nextCursor);
742
- continue;
743
- }
744
- const commitsBySeq = new Map();
745
- const commitSeqs = [];
746
- for await (const r of dialect.iterateIncrementalPullRows(trx, {
747
- partitionId,
748
- table: sub.table,
749
- scopes: effectiveScopes,
750
- cursor,
751
- limitCommits,
752
- })) {
753
- nextCursor = Math.max(nextCursor, r.commit_seq);
754
- const seq = r.commit_seq;
755
- let commit = commitsBySeq.get(seq);
756
- if (!commit) {
757
- commit = {
758
- commitSeq: seq,
759
- createdAt: r.created_at,
760
- actorId: r.actor_id,
761
- changes: [],
762
- };
763
- commitsBySeq.set(seq, commit);
764
- commitSeqs.push(seq);
765
- }
766
- const change = {
767
- table: r.table,
768
- row_id: r.row_id,
769
- op: r.op,
770
- row_json: r.row_json,
771
- row_version: r.row_version,
772
- scopes: r.scopes,
831
+ const effectiveScopes = mergeScopes(activeSubscriptions);
832
+ const clientCursor = nextCursors.length > 0
833
+ ? Math.min(...nextCursors)
834
+ : maxCommitSeq;
835
+ return {
836
+ response: {
837
+ ok: true,
838
+ subscriptions: subResponses,
839
+ },
840
+ effectiveScopes,
841
+ clientCursor,
773
842
  };
774
- commit.changes.push(change);
775
- }
776
- nextCursor = Math.max(nextCursor, maxScannedCommitSeq);
777
- if (commitSeqs.length === 0) {
778
- subResponses.push({
779
- id: sub.id,
780
- status: 'active',
781
- scopes: effectiveScopes,
782
- bootstrap: false,
783
- nextCursor,
784
- commits: [],
785
- });
786
- nextCursors.push(nextCursor);
787
- continue;
788
- }
789
- const commits = commitSeqs
790
- .map((seq) => commitsBySeq.get(seq))
791
- .filter((c) => !!c)
792
- .filter((c) => c.changes.length > 0);
793
- subResponses.push({
794
- id: sub.id,
795
- status: 'active',
796
- scopes: effectiveScopes,
797
- bootstrap: false,
798
- nextCursor,
799
- commits,
800
843
  });
801
- nextCursors.push(nextCursor);
802
- }
803
- const effectiveScopes = mergeScopes(activeSubscriptions);
804
- const clientCursor = nextCursors.length > 0 ? Math.min(...nextCursors) : maxCommitSeq;
805
- return {
806
- response: {
807
- ok: true,
808
- subscriptions: subResponses,
809
- },
810
- effectiveScopes,
811
- clientCursor,
812
- };
813
- });
814
- const chunkStorage = args.chunkStorage;
815
- if (chunkStorage && pendingExternalChunkWrites.length > 0) {
816
- await runWithConcurrency(pendingExternalChunkWrites, 4, async (pending) => {
817
- const cacheLookupStartedAt = Date.now();
818
- let chunkRef = await readSnapshotChunkRefByPageKey(db, {
819
- partitionId: pending.cacheLookup.partitionId,
820
- scopeKey: pending.cacheLookup.scopeKey,
821
- scope: pending.cacheLookup.scope,
822
- asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
823
- rowCursor: pending.cacheLookup.rowCursor,
824
- rowLimit: pending.cacheLookup.rowLimit,
825
- encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
826
- compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
827
- });
828
- bootstrapTimings.chunkCacheLookupMs += Math.max(0, Date.now() - cacheLookupStartedAt);
829
- if (!chunkRef) {
830
- if (chunkStorage.storeChunkStream) {
831
- const { stream: bodyStream, byteLength, sha256, gzipMs, hashMs, } = await encodeCompressedSnapshotChunkToStream(pending.rowFrameParts);
832
- bootstrapTimings.chunkGzipMs += gzipMs;
833
- bootstrapTimings.chunkHashMs += hashMs;
834
- const chunkPersistStartedAt = Date.now();
835
- chunkRef = await chunkStorage.storeChunkStream({
836
- partitionId: pending.cacheLookup.partitionId,
837
- scopeKey: pending.cacheLookup.scopeKey,
838
- scope: pending.cacheLookup.scope,
839
- asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
840
- rowCursor: pending.cacheLookup.rowCursor,
841
- rowLimit: pending.cacheLookup.rowLimit,
842
- encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
843
- compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
844
- sha256,
845
- byteLength,
846
- bodyStream,
847
- expiresAt: pending.expiresAt,
848
- });
849
- bootstrapTimings.chunkPersistMs += Math.max(0, Date.now() - chunkPersistStartedAt);
850
- }
851
- else {
852
- const encodedChunk = await encodeCompressedSnapshotChunk(pending.rowFrameParts);
853
- bootstrapTimings.chunkGzipMs += encodedChunk.gzipMs;
854
- bootstrapTimings.chunkHashMs += encodedChunk.hashMs;
855
- const chunkPersistStartedAt = Date.now();
856
- chunkRef = await chunkStorage.storeChunk({
844
+ const chunkStorage = args.chunkStorage;
845
+ if (chunkStorage && pendingExternalChunkWrites.length > 0) {
846
+ await runWithConcurrency(pendingExternalChunkWrites, 4, async (pending) => {
847
+ const cacheLookupStartedAt = Date.now();
848
+ let chunkRef = await readSnapshotChunkRefByPageKey(db, {
857
849
  partitionId: pending.cacheLookup.partitionId,
858
850
  scopeKey: pending.cacheLookup.scopeKey,
859
851
  scope: pending.cacheLookup.scope,
@@ -862,43 +854,93 @@ export async function pull(args) {
862
854
  rowLimit: pending.cacheLookup.rowLimit,
863
855
  encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
864
856
  compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
865
- sha256: encodedChunk.sha256,
866
- body: encodedChunk.body,
867
- expiresAt: pending.expiresAt,
868
857
  });
869
- bootstrapTimings.chunkPersistMs += Math.max(0, Date.now() - chunkPersistStartedAt);
870
- }
858
+ bootstrapTimings.chunkCacheLookupMs += Math.max(0, Date.now() - cacheLookupStartedAt);
859
+ if (!chunkRef) {
860
+ if (chunkStorage.storeChunkStream) {
861
+ const { stream: bodyStream, byteLength, sha256, gzipMs, hashMs, } = await encodeCompressedSnapshotChunkToStream(pending.rowFrameParts);
862
+ bootstrapTimings.chunkGzipMs += gzipMs;
863
+ bootstrapTimings.chunkHashMs += hashMs;
864
+ const chunkPersistStartedAt = Date.now();
865
+ chunkRef = await chunkStorage.storeChunkStream({
866
+ partitionId: pending.cacheLookup.partitionId,
867
+ scopeKey: pending.cacheLookup.scopeKey,
868
+ scope: pending.cacheLookup.scope,
869
+ asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
870
+ rowCursor: pending.cacheLookup.rowCursor,
871
+ rowLimit: pending.cacheLookup.rowLimit,
872
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
873
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
874
+ sha256,
875
+ byteLength,
876
+ bodyStream,
877
+ expiresAt: pending.expiresAt,
878
+ });
879
+ bootstrapTimings.chunkPersistMs += Math.max(0, Date.now() - chunkPersistStartedAt);
880
+ }
881
+ else {
882
+ const encodedChunk = await encodeCompressedSnapshotChunk(pending.rowFrameParts);
883
+ bootstrapTimings.chunkGzipMs += encodedChunk.gzipMs;
884
+ bootstrapTimings.chunkHashMs += encodedChunk.hashMs;
885
+ const chunkPersistStartedAt = Date.now();
886
+ chunkRef = await chunkStorage.storeChunk({
887
+ partitionId: pending.cacheLookup.partitionId,
888
+ scopeKey: pending.cacheLookup.scopeKey,
889
+ scope: pending.cacheLookup.scope,
890
+ asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
891
+ rowCursor: pending.cacheLookup.rowCursor,
892
+ rowLimit: pending.cacheLookup.rowLimit,
893
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
894
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
895
+ sha256: encodedChunk.sha256,
896
+ body: encodedChunk.body,
897
+ expiresAt: pending.expiresAt,
898
+ });
899
+ bootstrapTimings.chunkPersistMs += Math.max(0, Date.now() - chunkPersistStartedAt);
900
+ }
901
+ }
902
+ pending.snapshot.chunks = [chunkRef];
903
+ });
871
904
  }
872
- pending.snapshot.chunks = [chunkRef];
873
- });
905
+ const durationMs = Math.max(0, Date.now() - startedAtMs);
906
+ const stats = summarizePullResponse(result.response);
907
+ span.setAttribute('status', 'ok');
908
+ span.setAttribute('duration_ms', durationMs);
909
+ span.setAttribute('subscription_count', stats.subscriptionCount);
910
+ span.setAttribute('commit_count', stats.commitCount);
911
+ span.setAttribute('change_count', stats.changeCount);
912
+ span.setAttribute('snapshot_page_count', stats.snapshotPageCount);
913
+ span.setAttributes({
914
+ bootstrap_snapshot_query_ms: bootstrapTimings.snapshotQueryMs,
915
+ bootstrap_row_frame_encode_ms: bootstrapTimings.rowFrameEncodeMs,
916
+ bootstrap_chunk_cache_lookup_ms: bootstrapTimings.chunkCacheLookupMs,
917
+ bootstrap_chunk_gzip_ms: bootstrapTimings.chunkGzipMs,
918
+ bootstrap_chunk_hash_ms: bootstrapTimings.chunkHashMs,
919
+ bootstrap_chunk_persist_ms: bootstrapTimings.chunkPersistMs,
920
+ });
921
+ span.setStatus('ok');
922
+ recordPullMetrics({
923
+ status: 'ok',
924
+ dedupeRows,
925
+ durationMs,
926
+ stats,
927
+ });
928
+ return {
929
+ ...result,
930
+ bootstrapTimings,
931
+ };
932
+ }
933
+ catch (error) {
934
+ if (error instanceof Error &&
935
+ attemptIndex < MAX_PULL_TRANSACTION_RETRIES - 1 &&
936
+ isSerializablePullError(error)) {
937
+ await delay(PULL_TRANSACTION_RETRY_DELAY_MS * (attemptIndex + 1));
938
+ continue;
939
+ }
940
+ throw error;
941
+ }
874
942
  }
875
- const durationMs = Math.max(0, Date.now() - startedAtMs);
876
- const stats = summarizePullResponse(result.response);
877
- span.setAttribute('status', 'ok');
878
- span.setAttribute('duration_ms', durationMs);
879
- span.setAttribute('subscription_count', stats.subscriptionCount);
880
- span.setAttribute('commit_count', stats.commitCount);
881
- span.setAttribute('change_count', stats.changeCount);
882
- span.setAttribute('snapshot_page_count', stats.snapshotPageCount);
883
- span.setAttributes({
884
- bootstrap_snapshot_query_ms: bootstrapTimings.snapshotQueryMs,
885
- bootstrap_row_frame_encode_ms: bootstrapTimings.rowFrameEncodeMs,
886
- bootstrap_chunk_cache_lookup_ms: bootstrapTimings.chunkCacheLookupMs,
887
- bootstrap_chunk_gzip_ms: bootstrapTimings.chunkGzipMs,
888
- bootstrap_chunk_hash_ms: bootstrapTimings.chunkHashMs,
889
- bootstrap_chunk_persist_ms: bootstrapTimings.chunkPersistMs,
890
- });
891
- span.setStatus('ok');
892
- recordPullMetrics({
893
- status: 'ok',
894
- dedupeRows,
895
- durationMs,
896
- stats,
897
- });
898
- return {
899
- ...result,
900
- bootstrapTimings,
901
- };
943
+ throw new Error('Pull transaction retry loop exhausted unexpectedly');
902
944
  }
903
945
  catch (error) {
904
946
  const durationMs = Math.max(0, Date.now() - startedAtMs);