@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/src/pull.ts CHANGED
@@ -20,7 +20,11 @@ import {
20
20
  startSyncSpan,
21
21
  } from '@syncular/core';
22
22
  import type { Kysely } from 'kysely';
23
- import type { DbExecutor, ServerSyncDialect } from './dialect/types';
23
+ import type {
24
+ DbExecutor,
25
+ IncrementalPullRow,
26
+ ServerSyncDialect,
27
+ } from './dialect/types';
24
28
  import {
25
29
  getServerBootstrapOrderFor,
26
30
  type ServerHandlerCollection,
@@ -45,6 +49,8 @@ const DEFAULT_MAX_SNAPSHOT_BUNDLE_ROW_FRAME_BYTES = 512 * 1024;
45
49
  const MAX_ADAPTIVE_SNAPSHOT_BUNDLE_ROW_FRAME_BYTES = 4 * 1024 * 1024;
46
50
  const DEFAULT_INLINE_SNAPSHOT_ROW_FRAME_BYTES = 256 * 1024;
47
51
  const EMPTY_SNAPSHOT_ROW_FRAMES = encodeSnapshotRows([]);
52
+ const MAX_PULL_TRANSACTION_RETRIES = 2;
53
+ const PULL_TRANSACTION_RETRY_DELAY_MS = 15;
48
54
 
49
55
  interface PullBootstrapTimings {
50
56
  snapshotQueryMs: number;
@@ -348,6 +354,20 @@ function sanitizeLimit(
348
354
  return Math.max(min, Math.min(max, value));
349
355
  }
350
356
 
357
+ function isSerializablePullError(error: Error): boolean {
358
+ const withCode = error as Error & { code?: string };
359
+ return (
360
+ withCode.code === '40001' ||
361
+ error.message.toLowerCase().includes('could not serialize access')
362
+ );
363
+ }
364
+
365
+ async function delay(ms: number): Promise<void> {
366
+ await new Promise((resolve) => {
367
+ setTimeout(resolve, ms);
368
+ });
369
+ }
370
+
351
371
  /**
352
372
  * Merge all scope values into a flat ScopeValues for cursor tracking.
353
373
  */
@@ -567,9 +587,6 @@ export async function pull<
567
587
  50
568
588
  );
569
589
  const dedupeRows = request.dedupeRows === true;
570
- const pendingExternalChunkWrites: PendingExternalChunkWrite[] = [];
571
- const bootstrapTimings = createPullBootstrapTimings();
572
-
573
590
  // Resolve effective scopes for each subscription
574
591
  const resolved = await resolveEffectiveScopesForSubscriptions({
575
592
  db,
@@ -579,630 +596,646 @@ export async function pull<
579
596
  scopeCache: args.scopeCache ?? defaultScopeCache,
580
597
  });
581
598
 
582
- const result = await dialect.executeInTransaction(db, async (trx) => {
583
- await dialect.setRepeatableRead(trx);
584
-
585
- const maxCommitSeq = await dialect.readMaxCommitSeq(trx, {
586
- partitionId,
587
- });
588
- const minCommitSeq = await dialect.readMinCommitSeq(trx, {
589
- partitionId,
590
- });
591
-
592
- const subResponses: SyncPullSubscriptionResponse[] = [];
593
- const activeSubscriptions: { scopes: ScopeValues }[] = [];
594
- const nextCursors: number[] = [];
595
-
596
- // Detect external data changes (synthetic commits from notifyExternalDataChange)
597
- // Compute minimum cursor across all active subscriptions to scope the query.
598
- let minSubCursor = Number.MAX_SAFE_INTEGER;
599
- const activeTables = new Set<string>();
600
- for (const sub of resolved) {
601
- if (
602
- sub.status === 'revoked' ||
603
- Object.keys(sub.scopes).length === 0
604
- )
605
- continue;
606
- activeTables.add(sub.table);
607
- const cursor = Math.max(-1, sub.cursor ?? -1);
608
- if (cursor >= 0 && cursor < minSubCursor) {
609
- minSubCursor = cursor;
610
- }
611
- }
612
-
613
- const maxExternalCommitByTable =
614
- minSubCursor < Number.MAX_SAFE_INTEGER && minSubCursor >= 0
615
- ? await readLatestExternalCommitByTable(trx, {
599
+ for (
600
+ let attemptIndex = 0;
601
+ attemptIndex < MAX_PULL_TRANSACTION_RETRIES;
602
+ attemptIndex += 1
603
+ ) {
604
+ const pendingExternalChunkWrites: PendingExternalChunkWrite[] = [];
605
+ const bootstrapTimings = createPullBootstrapTimings();
606
+
607
+ try {
608
+ const result = await dialect.executeInTransaction(
609
+ db,
610
+ async (trx) => {
611
+ await dialect.setRepeatableRead(trx);
612
+
613
+ const maxCommitSeq = await dialect.readMaxCommitSeq(trx, {
614
+ partitionId,
615
+ });
616
+ const minCommitSeq = await dialect.readMinCommitSeq(trx, {
616
617
  partitionId,
617
- afterCursor: minSubCursor,
618
- tables: Array.from(activeTables),
619
- })
620
- : new Map<string, number>();
621
-
622
- for (const sub of resolved) {
623
- const cursor = Math.max(-1, sub.cursor ?? -1);
624
- // Validate table handler exists (throws if not registered)
625
- if (!args.handlers.byTable.has(sub.table)) {
626
- throw new Error(`Unknown table: ${sub.table}`);
627
- }
628
-
629
- if (
630
- sub.status === 'revoked' ||
631
- Object.keys(sub.scopes).length === 0
632
- ) {
633
- subResponses.push({
634
- id: sub.id,
635
- status: 'revoked',
636
- scopes: {},
637
- bootstrap: false,
638
- nextCursor: cursor,
639
- commits: [],
640
- });
641
- continue;
642
- }
643
-
644
- const effectiveScopes = sub.scopes;
645
- activeSubscriptions.push({ scopes: effectiveScopes });
646
- const latestExternalCommitForTable = maxExternalCommitByTable.get(
647
- sub.table
648
- );
649
-
650
- const needsBootstrap =
651
- sub.bootstrapState != null ||
652
- cursor < 0 ||
653
- cursor > maxCommitSeq ||
654
- (minCommitSeq > 0 && cursor < minCommitSeq - 1) ||
655
- (latestExternalCommitForTable !== undefined &&
656
- latestExternalCommitForTable > cursor);
657
-
658
- if (needsBootstrap) {
659
- const tables = getServerBootstrapOrderFor(
660
- args.handlers,
661
- sub.table
662
- ).map((handler) => handler.table);
663
- const preferInlineBootstrapSnapshot =
664
- cursor >= 0 ||
665
- sub.bootstrapState != null ||
666
- (latestExternalCommitForTable !== undefined &&
667
- latestExternalCommitForTable > cursor);
668
-
669
- const initState: SyncBootstrapState = {
670
- asOfCommitSeq: maxCommitSeq,
671
- tables,
672
- tableIndex: 0,
673
- rowCursor: null,
674
- };
675
-
676
- const requestedState = sub.bootstrapState ?? null;
677
- const state =
678
- requestedState &&
679
- typeof requestedState.asOfCommitSeq === 'number' &&
680
- Array.isArray(requestedState.tables) &&
681
- typeof requestedState.tableIndex === 'number'
682
- ? (requestedState as SyncBootstrapState)
683
- : initState;
684
-
685
- // If the bootstrap state's asOfCommitSeq is no longer catch-up-able, restart bootstrap.
686
- const effectiveState =
687
- state.asOfCommitSeq < minCommitSeq - 1 ? initState : state;
688
-
689
- const tableName =
690
- effectiveState.tables[effectiveState.tableIndex];
691
-
692
- // No tables (or ran past the end): treat bootstrap as complete.
693
- if (!tableName) {
694
- subResponses.push({
695
- id: sub.id,
696
- status: 'active',
697
- scopes: effectiveScopes,
698
- bootstrap: true,
699
- bootstrapState: null,
700
- nextCursor: effectiveState.asOfCommitSeq,
701
- commits: [],
702
- snapshots: [],
703
618
  });
704
- nextCursors.push(effectiveState.asOfCommitSeq);
705
- continue;
706
- }
707
-
708
- const snapshots: SyncSnapshot[] = [];
709
- let nextState: SyncBootstrapState | null = effectiveState;
710
- const cacheKey = `${partitionId}:${await scopesToSnapshotChunkScopeKey(
711
- effectiveScopes
712
- )}`;
713
-
714
- interface SnapshotBundle {
715
- table: string;
716
- startCursor: string | null;
717
- isFirstPage: boolean;
718
- isLastPage: boolean;
719
- pageCount: number;
720
- ttlMs: number;
721
- rowFrameByteLength: number;
722
- rowFrameParts: Uint8Array[];
723
- inlineRows: unknown[] | null;
724
- }
725
619
 
726
- const flushSnapshotBundle = async (
727
- bundle: SnapshotBundle
728
- ): Promise<void> => {
729
- if (bundle.inlineRows) {
730
- snapshots.push({
731
- table: bundle.table,
732
- rows: bundle.inlineRows,
733
- isFirstPage: bundle.isFirstPage,
734
- isLastPage: bundle.isLastPage,
735
- });
736
- return;
620
+ const subResponses: SyncPullSubscriptionResponse[] = [];
621
+ const activeSubscriptions: { scopes: ScopeValues }[] = [];
622
+ const nextCursors: number[] = [];
623
+
624
+ // Detect external data changes (synthetic commits from notifyExternalDataChange)
625
+ // Compute minimum cursor across all active subscriptions to scope the query.
626
+ let minSubCursor = Number.MAX_SAFE_INTEGER;
627
+ const activeTables = new Set<string>();
628
+ for (const sub of resolved) {
629
+ if (
630
+ sub.status === 'revoked' ||
631
+ Object.keys(sub.scopes).length === 0
632
+ )
633
+ continue;
634
+ activeTables.add(sub.table);
635
+ const cursor = Math.max(-1, sub.cursor ?? -1);
636
+ if (cursor >= 0 && cursor < minSubCursor) {
637
+ minSubCursor = cursor;
638
+ }
737
639
  }
738
640
 
739
- const nowIso = new Date().toISOString();
740
- const bundleRowLimit = Math.max(
741
- 1,
742
- limitSnapshotRows * bundle.pageCount
743
- );
641
+ const maxExternalCommitByTable =
642
+ minSubCursor < Number.MAX_SAFE_INTEGER && minSubCursor >= 0
643
+ ? await readLatestExternalCommitByTable(trx, {
644
+ partitionId,
645
+ afterCursor: minSubCursor,
646
+ tables: Array.from(activeTables),
647
+ })
648
+ : new Map<string, number>();
649
+
650
+ for (const sub of resolved) {
651
+ const cursor = Math.max(-1, sub.cursor ?? -1);
652
+ // Validate table handler exists (throws if not registered)
653
+ if (!args.handlers.byTable.has(sub.table)) {
654
+ throw new Error(`Unknown table: ${sub.table}`);
655
+ }
744
656
 
745
- const cacheLookupStartedAt = Date.now();
746
- const cached = await readSnapshotChunkRefByPageKey(trx, {
747
- partitionId,
748
- scopeKey: cacheKey,
749
- scope: bundle.table,
750
- asOfCommitSeq: effectiveState.asOfCommitSeq,
751
- rowCursor: bundle.startCursor,
752
- rowLimit: bundleRowLimit,
753
- encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
754
- compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
755
- nowIso,
756
- });
757
- bootstrapTimings.chunkCacheLookupMs += Math.max(
758
- 0,
759
- Date.now() - cacheLookupStartedAt
760
- );
761
-
762
- let chunkRef = cached;
763
- if (!chunkRef) {
764
- const expiresAt = new Date(
765
- Date.now() + Math.max(1000, bundle.ttlMs)
766
- ).toISOString();
767
-
768
- if (args.chunkStorage) {
769
- const snapshot: SyncSnapshot = {
770
- table: bundle.table,
771
- rows: [],
772
- chunks: [],
773
- isFirstPage: bundle.isFirstPage,
774
- isLastPage: bundle.isLastPage,
657
+ if (
658
+ sub.status === 'revoked' ||
659
+ Object.keys(sub.scopes).length === 0
660
+ ) {
661
+ subResponses.push({
662
+ id: sub.id,
663
+ status: 'revoked',
664
+ scopes: {},
665
+ bootstrap: false,
666
+ nextCursor: cursor,
667
+ commits: [],
668
+ });
669
+ continue;
670
+ }
671
+
672
+ const effectiveScopes = sub.scopes;
673
+ activeSubscriptions.push({ scopes: effectiveScopes });
674
+ const latestExternalCommitForTable =
675
+ maxExternalCommitByTable.get(sub.table);
676
+
677
+ const needsBootstrap =
678
+ sub.bootstrapState != null ||
679
+ cursor < 0 ||
680
+ cursor > maxCommitSeq ||
681
+ (minCommitSeq > 0 && cursor < minCommitSeq - 1) ||
682
+ (latestExternalCommitForTable !== undefined &&
683
+ latestExternalCommitForTable > cursor);
684
+
685
+ if (needsBootstrap) {
686
+ const tables = getServerBootstrapOrderFor(
687
+ args.handlers,
688
+ sub.table
689
+ ).map((handler) => handler.table);
690
+ const preferInlineBootstrapSnapshot =
691
+ cursor >= 0 ||
692
+ sub.bootstrapState != null ||
693
+ (latestExternalCommitForTable !== undefined &&
694
+ latestExternalCommitForTable > cursor);
695
+
696
+ const initState: SyncBootstrapState = {
697
+ asOfCommitSeq: maxCommitSeq,
698
+ tables,
699
+ tableIndex: 0,
700
+ rowCursor: null,
775
701
  };
776
- snapshots.push(snapshot);
777
- pendingExternalChunkWrites.push({
778
- snapshot,
779
- cacheLookup: {
702
+
703
+ const requestedState = sub.bootstrapState ?? null;
704
+ const state =
705
+ requestedState &&
706
+ typeof requestedState.asOfCommitSeq === 'number' &&
707
+ Array.isArray(requestedState.tables) &&
708
+ typeof requestedState.tableIndex === 'number'
709
+ ? (requestedState as SyncBootstrapState)
710
+ : initState;
711
+
712
+ // If the bootstrap state's asOfCommitSeq is no longer catch-up-able, restart bootstrap.
713
+ const effectiveState =
714
+ state.asOfCommitSeq < minCommitSeq - 1
715
+ ? initState
716
+ : state;
717
+
718
+ const tableName =
719
+ effectiveState.tables[effectiveState.tableIndex];
720
+
721
+ // No tables (or ran past the end): treat bootstrap as complete.
722
+ if (!tableName) {
723
+ subResponses.push({
724
+ id: sub.id,
725
+ status: 'active',
726
+ scopes: effectiveScopes,
727
+ bootstrap: true,
728
+ bootstrapState: null,
729
+ nextCursor: effectiveState.asOfCommitSeq,
730
+ commits: [],
731
+ snapshots: [],
732
+ });
733
+ nextCursors.push(effectiveState.asOfCommitSeq);
734
+ continue;
735
+ }
736
+
737
+ const snapshots: SyncSnapshot[] = [];
738
+ let nextState: SyncBootstrapState | null = effectiveState;
739
+ const cacheKey = `${partitionId}:${await scopesToSnapshotChunkScopeKey(
740
+ effectiveScopes
741
+ )}`;
742
+
743
+ interface SnapshotBundle {
744
+ table: string;
745
+ startCursor: string | null;
746
+ isFirstPage: boolean;
747
+ isLastPage: boolean;
748
+ pageCount: number;
749
+ ttlMs: number;
750
+ rowFrameByteLength: number;
751
+ rowFrameParts: Uint8Array[];
752
+ inlineRows: unknown[] | null;
753
+ }
754
+
755
+ const flushSnapshotBundle = async (
756
+ bundle: SnapshotBundle
757
+ ): Promise<void> => {
758
+ if (bundle.inlineRows) {
759
+ snapshots.push({
760
+ table: bundle.table,
761
+ rows: bundle.inlineRows,
762
+ isFirstPage: bundle.isFirstPage,
763
+ isLastPage: bundle.isLastPage,
764
+ });
765
+ return;
766
+ }
767
+
768
+ const nowIso = new Date().toISOString();
769
+ const bundleRowLimit = Math.max(
770
+ 1,
771
+ limitSnapshotRows * bundle.pageCount
772
+ );
773
+
774
+ const cacheLookupStartedAt = Date.now();
775
+ const cached = await readSnapshotChunkRefByPageKey(trx, {
780
776
  partitionId,
781
777
  scopeKey: cacheKey,
782
778
  scope: bundle.table,
783
779
  asOfCommitSeq: effectiveState.asOfCommitSeq,
784
780
  rowCursor: bundle.startCursor,
785
781
  rowLimit: bundleRowLimit,
786
- },
787
- rowFrameParts: [...bundle.rowFrameParts],
788
- expiresAt,
789
- });
790
- return;
791
- }
792
- const encodedChunk = await encodeCompressedSnapshotChunk(
793
- bundle.rowFrameParts
794
- );
795
- bootstrapTimings.chunkGzipMs += encodedChunk.gzipMs;
796
- bootstrapTimings.chunkHashMs += encodedChunk.hashMs;
797
- const chunkId = randomId();
798
- const chunkPersistStartedAt = Date.now();
799
- chunkRef = await insertSnapshotChunk(trx, {
800
- chunkId,
801
- partitionId,
802
- scopeKey: cacheKey,
803
- scope: bundle.table,
804
- asOfCommitSeq: effectiveState.asOfCommitSeq,
805
- rowCursor: bundle.startCursor,
806
- rowLimit: bundleRowLimit,
807
- encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
808
- compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
809
- sha256: encodedChunk.sha256,
810
- body: encodedChunk.body,
811
- expiresAt,
812
- });
813
- bootstrapTimings.chunkPersistMs += Math.max(
814
- 0,
815
- Date.now() - chunkPersistStartedAt
816
- );
817
- }
782
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
783
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
784
+ nowIso,
785
+ });
786
+ bootstrapTimings.chunkCacheLookupMs += Math.max(
787
+ 0,
788
+ Date.now() - cacheLookupStartedAt
789
+ );
790
+
791
+ let chunkRef = cached;
792
+ if (!chunkRef) {
793
+ const expiresAt = new Date(
794
+ Date.now() + Math.max(1000, bundle.ttlMs)
795
+ ).toISOString();
796
+
797
+ if (args.chunkStorage) {
798
+ const snapshot: SyncSnapshot = {
799
+ table: bundle.table,
800
+ rows: [],
801
+ chunks: [],
802
+ isFirstPage: bundle.isFirstPage,
803
+ isLastPage: bundle.isLastPage,
804
+ };
805
+ snapshots.push(snapshot);
806
+ pendingExternalChunkWrites.push({
807
+ snapshot,
808
+ cacheLookup: {
809
+ partitionId,
810
+ scopeKey: cacheKey,
811
+ scope: bundle.table,
812
+ asOfCommitSeq: effectiveState.asOfCommitSeq,
813
+ rowCursor: bundle.startCursor,
814
+ rowLimit: bundleRowLimit,
815
+ },
816
+ rowFrameParts: [...bundle.rowFrameParts],
817
+ expiresAt,
818
+ });
819
+ return;
820
+ }
821
+ const encodedChunk =
822
+ await encodeCompressedSnapshotChunk(
823
+ bundle.rowFrameParts
824
+ );
825
+ bootstrapTimings.chunkGzipMs += encodedChunk.gzipMs;
826
+ bootstrapTimings.chunkHashMs += encodedChunk.hashMs;
827
+ const chunkId = randomId();
828
+ const chunkPersistStartedAt = Date.now();
829
+ chunkRef = await insertSnapshotChunk(trx, {
830
+ chunkId,
831
+ partitionId,
832
+ scopeKey: cacheKey,
833
+ scope: bundle.table,
834
+ asOfCommitSeq: effectiveState.asOfCommitSeq,
835
+ rowCursor: bundle.startCursor,
836
+ rowLimit: bundleRowLimit,
837
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
838
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
839
+ sha256: encodedChunk.sha256,
840
+ body: encodedChunk.body,
841
+ expiresAt,
842
+ });
843
+ bootstrapTimings.chunkPersistMs += Math.max(
844
+ 0,
845
+ Date.now() - chunkPersistStartedAt
846
+ );
847
+ }
848
+
849
+ snapshots.push({
850
+ table: bundle.table,
851
+ rows: [],
852
+ chunks: [chunkRef],
853
+ isFirstPage: bundle.isFirstPage,
854
+ isLastPage: bundle.isLastPage,
855
+ });
856
+ };
818
857
 
819
- snapshots.push({
820
- table: bundle.table,
821
- rows: [],
822
- chunks: [chunkRef],
823
- isFirstPage: bundle.isFirstPage,
824
- isLastPage: bundle.isLastPage,
825
- });
826
- };
827
-
828
- let activeBundle: SnapshotBundle | null = null;
829
-
830
- for (
831
- let pageIndex = 0;
832
- pageIndex < maxSnapshotPages;
833
- pageIndex++
834
- ) {
835
- if (!nextState) break;
836
-
837
- const nextTableName: string | undefined =
838
- nextState.tables[nextState.tableIndex];
839
- if (!nextTableName) {
840
- if (activeBundle) {
841
- activeBundle.isLastPage = true;
842
- await flushSnapshotBundle(activeBundle);
843
- activeBundle = null;
858
+ let activeBundle: SnapshotBundle | null = null;
859
+
860
+ for (
861
+ let pageIndex = 0;
862
+ pageIndex < maxSnapshotPages;
863
+ pageIndex++
864
+ ) {
865
+ if (!nextState) break;
866
+
867
+ const nextTableName: string | undefined =
868
+ nextState.tables[nextState.tableIndex];
869
+ if (!nextTableName) {
870
+ if (activeBundle) {
871
+ activeBundle.isLastPage = true;
872
+ await flushSnapshotBundle(activeBundle);
873
+ activeBundle = null;
874
+ }
875
+ nextState = null;
876
+ break;
877
+ }
878
+
879
+ const tableHandler =
880
+ args.handlers.byTable.get(nextTableName);
881
+ if (!tableHandler) {
882
+ throw new Error(`Unknown table: ${nextTableName}`);
883
+ }
884
+ if (
885
+ !activeBundle ||
886
+ activeBundle.table !== nextTableName
887
+ ) {
888
+ if (activeBundle) {
889
+ await flushSnapshotBundle(activeBundle);
890
+ }
891
+ const bundleHeader = EMPTY_SNAPSHOT_ROW_FRAMES;
892
+ activeBundle = {
893
+ table: nextTableName,
894
+ startCursor: nextState.rowCursor,
895
+ isFirstPage: nextState.rowCursor == null,
896
+ isLastPage: false,
897
+ pageCount: 0,
898
+ ttlMs:
899
+ tableHandler.snapshotChunkTtlMs ??
900
+ 24 * 60 * 60 * 1000,
901
+ rowFrameByteLength: bundleHeader.length,
902
+ rowFrameParts: [bundleHeader],
903
+ inlineRows: null,
904
+ };
905
+ }
906
+
907
+ const snapshotQueryStartedAt = Date.now();
908
+ const page: {
909
+ rows: unknown[];
910
+ nextCursor: string | null;
911
+ } = await tableHandler.snapshot(
912
+ {
913
+ db: trx,
914
+ actorId: args.auth.actorId,
915
+ auth: args.auth,
916
+ scopeValues: effectiveScopes,
917
+ cursor: nextState.rowCursor,
918
+ limit: limitSnapshotRows,
919
+ },
920
+ sub.params
921
+ );
922
+ bootstrapTimings.snapshotQueryMs += Math.max(
923
+ 0,
924
+ Date.now() - snapshotQueryStartedAt
925
+ );
926
+
927
+ const rowFrameEncodeStartedAt = Date.now();
928
+ const rowFrames = encodeSnapshotRowFrames(
929
+ page.rows ?? []
930
+ );
931
+ bootstrapTimings.rowFrameEncodeMs += Math.max(
932
+ 0,
933
+ Date.now() - rowFrameEncodeStartedAt
934
+ );
935
+ const bundleMaxBytes = resolveSnapshotBundleMaxBytes({
936
+ configuredMaxBytes: tableHandler.snapshotBundleMaxBytes,
937
+ pageRowCount: page.rows?.length ?? 0,
938
+ pageRowFrameBytes: rowFrames.length,
939
+ });
940
+ if (
941
+ activeBundle.pageCount > 0 &&
942
+ activeBundle.rowFrameByteLength + rowFrames.length >
943
+ bundleMaxBytes
944
+ ) {
945
+ await flushSnapshotBundle(activeBundle);
946
+ const bundleHeader = EMPTY_SNAPSHOT_ROW_FRAMES;
947
+ activeBundle = {
948
+ table: nextTableName,
949
+ startCursor: nextState.rowCursor,
950
+ isFirstPage: nextState.rowCursor == null,
951
+ isLastPage: false,
952
+ pageCount: 0,
953
+ ttlMs:
954
+ tableHandler.snapshotChunkTtlMs ??
955
+ 24 * 60 * 60 * 1000,
956
+ rowFrameByteLength: bundleHeader.length,
957
+ rowFrameParts: [bundleHeader],
958
+ inlineRows: null,
959
+ };
960
+ }
961
+
962
+ if (
963
+ preferInlineBootstrapSnapshot &&
964
+ activeBundle.pageCount === 0 &&
965
+ page.nextCursor == null &&
966
+ rowFrames.length <=
967
+ DEFAULT_INLINE_SNAPSHOT_ROW_FRAME_BYTES
968
+ ) {
969
+ activeBundle.inlineRows = page.rows ?? [];
970
+ } else {
971
+ activeBundle.inlineRows = null;
972
+ }
973
+ activeBundle.rowFrameParts.push(rowFrames);
974
+ activeBundle.rowFrameByteLength += rowFrames.length;
975
+ activeBundle.pageCount += 1;
976
+
977
+ if (page.nextCursor != null) {
978
+ nextState = {
979
+ ...nextState,
980
+ rowCursor: page.nextCursor,
981
+ };
982
+ continue;
983
+ }
984
+
985
+ activeBundle.isLastPage = true;
986
+ await flushSnapshotBundle(activeBundle);
987
+ activeBundle = null;
988
+
989
+ if (nextState.tableIndex + 1 < nextState.tables.length) {
990
+ nextState = {
991
+ ...nextState,
992
+ tableIndex: nextState.tableIndex + 1,
993
+ rowCursor: null,
994
+ };
995
+ continue;
996
+ }
997
+
998
+ nextState = null;
999
+ break;
1000
+ }
1001
+
1002
+ if (activeBundle) {
1003
+ await flushSnapshotBundle(activeBundle);
1004
+ }
1005
+
1006
+ subResponses.push({
1007
+ id: sub.id,
1008
+ status: 'active',
1009
+ scopes: effectiveScopes,
1010
+ bootstrap: true,
1011
+ bootstrapState: nextState,
1012
+ nextCursor: effectiveState.asOfCommitSeq,
1013
+ commits: [],
1014
+ snapshots,
1015
+ });
1016
+ nextCursors.push(effectiveState.asOfCommitSeq);
1017
+ continue;
844
1018
  }
845
- nextState = null;
846
- break;
847
- }
848
1019
 
849
- const tableHandler = args.handlers.byTable.get(nextTableName);
850
- if (!tableHandler) {
851
- throw new Error(`Unknown table: ${nextTableName}`);
852
- }
853
- if (!activeBundle || activeBundle.table !== nextTableName) {
854
- if (activeBundle) {
855
- await flushSnapshotBundle(activeBundle);
856
- }
857
- const bundleHeader = EMPTY_SNAPSHOT_ROW_FRAMES;
858
- activeBundle = {
859
- table: nextTableName,
860
- startCursor: nextState.rowCursor,
861
- isFirstPage: nextState.rowCursor == null,
862
- isLastPage: false,
863
- pageCount: 0,
864
- ttlMs:
865
- tableHandler.snapshotChunkTtlMs ?? 24 * 60 * 60 * 1000,
866
- rowFrameByteLength: bundleHeader.length,
867
- rowFrameParts: [bundleHeader],
868
- inlineRows: null,
869
- };
870
- }
1020
+ // Incremental pull for this subscription. The dialect row query
1021
+ // carries the scanned commit-window max when matching rows exist,
1022
+ // so we only need a separate commit-window scan when the row query
1023
+ // returns no matches at all.
1024
+ const incrementalRows: IncrementalPullRow[] = [];
1025
+ let maxScannedCommitSeq = cursor;
871
1026
 
872
- const snapshotQueryStartedAt = Date.now();
873
- const page: { rows: unknown[]; nextCursor: string | null } =
874
- await tableHandler.snapshot(
1027
+ for await (const row of dialect.iterateIncrementalPullRows(
1028
+ trx,
875
1029
  {
876
- db: trx,
877
- actorId: args.auth.actorId,
878
- auth: args.auth,
879
- scopeValues: effectiveScopes,
880
- cursor: nextState.rowCursor,
881
- limit: limitSnapshotRows,
882
- },
883
- sub.params
884
- );
885
- bootstrapTimings.snapshotQueryMs += Math.max(
886
- 0,
887
- Date.now() - snapshotQueryStartedAt
888
- );
889
-
890
- const rowFrameEncodeStartedAt = Date.now();
891
- const rowFrames = encodeSnapshotRowFrames(page.rows ?? []);
892
- bootstrapTimings.rowFrameEncodeMs += Math.max(
893
- 0,
894
- Date.now() - rowFrameEncodeStartedAt
895
- );
896
- const bundleMaxBytes = resolveSnapshotBundleMaxBytes({
897
- configuredMaxBytes: tableHandler.snapshotBundleMaxBytes,
898
- pageRowCount: page.rows?.length ?? 0,
899
- pageRowFrameBytes: rowFrames.length,
900
- });
901
- if (
902
- activeBundle.pageCount > 0 &&
903
- activeBundle.rowFrameByteLength + rowFrames.length >
904
- bundleMaxBytes
905
- ) {
906
- await flushSnapshotBundle(activeBundle);
907
- const bundleHeader = EMPTY_SNAPSHOT_ROW_FRAMES;
908
- activeBundle = {
909
- table: nextTableName,
910
- startCursor: nextState.rowCursor,
911
- isFirstPage: nextState.rowCursor == null,
912
- isLastPage: false,
913
- pageCount: 0,
914
- ttlMs:
915
- tableHandler.snapshotChunkTtlMs ?? 24 * 60 * 60 * 1000,
916
- rowFrameByteLength: bundleHeader.length,
917
- rowFrameParts: [bundleHeader],
918
- inlineRows: null,
919
- };
920
- }
921
-
922
- if (
923
- preferInlineBootstrapSnapshot &&
924
- activeBundle.pageCount === 0 &&
925
- page.nextCursor == null &&
926
- rowFrames.length <= DEFAULT_INLINE_SNAPSHOT_ROW_FRAME_BYTES
927
- ) {
928
- activeBundle.inlineRows = page.rows ?? [];
929
- } else {
930
- activeBundle.inlineRows = null;
931
- }
932
- activeBundle.rowFrameParts.push(rowFrames);
933
- activeBundle.rowFrameByteLength += rowFrames.length;
934
- activeBundle.pageCount += 1;
935
-
936
- if (page.nextCursor != null) {
937
- nextState = { ...nextState, rowCursor: page.nextCursor };
938
- continue;
939
- }
940
-
941
- activeBundle.isLastPage = true;
942
- await flushSnapshotBundle(activeBundle);
943
- activeBundle = null;
944
-
945
- if (nextState.tableIndex + 1 < nextState.tables.length) {
946
- nextState = {
947
- ...nextState,
948
- tableIndex: nextState.tableIndex + 1,
949
- rowCursor: null,
950
- };
951
- continue;
952
- }
953
-
954
- nextState = null;
955
- break;
956
- }
957
-
958
- if (activeBundle) {
959
- await flushSnapshotBundle(activeBundle);
960
- }
961
-
962
- subResponses.push({
963
- id: sub.id,
964
- status: 'active',
965
- scopes: effectiveScopes,
966
- bootstrap: true,
967
- bootstrapState: nextState,
968
- nextCursor: effectiveState.asOfCommitSeq,
969
- commits: [],
970
- snapshots,
971
- });
972
- nextCursors.push(effectiveState.asOfCommitSeq);
973
- continue;
974
- }
975
-
976
- // Incremental pull for this subscription
977
- // Read the commit window for this table up-front so the subscription cursor
978
- // can advance past commits that don't match the requested scopes.
979
- const scannedCommitSeqs = await dialect.readCommitSeqsForPull(trx, {
980
- partitionId,
981
- cursor,
982
- limitCommits,
983
- tables: [sub.table],
984
- });
985
- const maxScannedCommitSeq =
986
- scannedCommitSeqs.length > 0
987
- ? scannedCommitSeqs[scannedCommitSeqs.length - 1]!
988
- : cursor;
989
-
990
- if (scannedCommitSeqs.length === 0) {
991
- subResponses.push({
992
- id: sub.id,
993
- status: 'active',
994
- scopes: effectiveScopes,
995
- bootstrap: false,
996
- nextCursor: cursor,
997
- commits: [],
998
- });
999
- nextCursors.push(cursor);
1000
- continue;
1001
- }
1030
+ partitionId,
1031
+ table: sub.table,
1032
+ scopes: effectiveScopes,
1033
+ cursor,
1034
+ limitCommits,
1035
+ }
1036
+ )) {
1037
+ incrementalRows.push(row);
1038
+ maxScannedCommitSeq = Math.max(
1039
+ maxScannedCommitSeq,
1040
+ row.scanned_max_commit_seq ?? row.commit_seq
1041
+ );
1042
+ }
1002
1043
 
1003
- let nextCursor = cursor;
1044
+ if (incrementalRows.length === 0) {
1045
+ const scannedCommitSeqs =
1046
+ await dialect.readCommitSeqsForPull(trx, {
1047
+ partitionId,
1048
+ cursor,
1049
+ limitCommits,
1050
+ tables: [sub.table],
1051
+ });
1052
+ maxScannedCommitSeq =
1053
+ scannedCommitSeqs.length > 0
1054
+ ? scannedCommitSeqs[scannedCommitSeqs.length - 1]!
1055
+ : cursor;
1056
+
1057
+ if (scannedCommitSeqs.length === 0) {
1058
+ subResponses.push({
1059
+ id: sub.id,
1060
+ status: 'active',
1061
+ scopes: effectiveScopes,
1062
+ bootstrap: false,
1063
+ nextCursor: cursor,
1064
+ commits: [],
1065
+ });
1066
+ nextCursors.push(cursor);
1067
+ continue;
1068
+ }
1069
+ }
1004
1070
 
1005
- if (dedupeRows) {
1006
- const latestByRowKey = new Map<
1007
- string,
1008
- {
1009
- commitSeq: number;
1010
- createdAt: string;
1011
- actorId: string;
1012
- change: SyncChange;
1013
- }
1014
- >();
1015
-
1016
- for await (const r of dialect.iterateIncrementalPullRows(trx, {
1017
- partitionId,
1018
- table: sub.table,
1019
- scopes: effectiveScopes,
1020
- cursor,
1021
- limitCommits,
1022
- })) {
1023
- nextCursor = Math.max(nextCursor, r.commit_seq);
1024
- const rowKey = `${r.table}\u0000${r.row_id}`;
1025
- const change: SyncChange = {
1026
- table: r.table,
1027
- row_id: r.row_id,
1028
- op: r.op,
1029
- row_json: r.row_json,
1030
- row_version: r.row_version,
1031
- scopes: r.scopes,
1032
- };
1071
+ let nextCursor = cursor;
1072
+
1073
+ if (dedupeRows) {
1074
+ const latestByRowKey = new Map<
1075
+ string,
1076
+ {
1077
+ commitSeq: number;
1078
+ createdAt: string;
1079
+ actorId: string;
1080
+ change: SyncChange;
1081
+ }
1082
+ >();
1083
+
1084
+ for (const r of incrementalRows) {
1085
+ nextCursor = Math.max(nextCursor, r.commit_seq);
1086
+ const rowKey = `${r.table}\u0000${r.row_id}`;
1087
+ const change: SyncChange = {
1088
+ table: r.table,
1089
+ row_id: r.row_id,
1090
+ op: r.op,
1091
+ row_json: r.row_json,
1092
+ row_version: r.row_version,
1093
+ scopes: r.scopes,
1094
+ };
1095
+
1096
+ // Move row keys to insertion tail so Map iteration yields
1097
+ // "latest change wins" order without a full array sort.
1098
+ if (latestByRowKey.has(rowKey)) {
1099
+ latestByRowKey.delete(rowKey);
1100
+ }
1101
+ latestByRowKey.set(rowKey, {
1102
+ commitSeq: r.commit_seq,
1103
+ createdAt: r.created_at,
1104
+ actorId: r.actor_id,
1105
+ change,
1106
+ });
1107
+ }
1108
+
1109
+ nextCursor = Math.max(nextCursor, maxScannedCommitSeq);
1110
+
1111
+ if (latestByRowKey.size === 0) {
1112
+ subResponses.push({
1113
+ id: sub.id,
1114
+ status: 'active',
1115
+ scopes: effectiveScopes,
1116
+ bootstrap: false,
1117
+ nextCursor,
1118
+ commits: [],
1119
+ });
1120
+ nextCursors.push(nextCursor);
1121
+ continue;
1122
+ }
1123
+
1124
+ const commits: SyncCommit[] = [];
1125
+ for (const item of latestByRowKey.values()) {
1126
+ const lastCommit = commits[commits.length - 1];
1127
+ if (
1128
+ !lastCommit ||
1129
+ lastCommit.commitSeq !== item.commitSeq
1130
+ ) {
1131
+ commits.push({
1132
+ commitSeq: item.commitSeq,
1133
+ createdAt: item.createdAt,
1134
+ actorId: item.actorId,
1135
+ changes: [item.change],
1136
+ });
1137
+ continue;
1138
+ }
1139
+ lastCommit.changes.push(item.change);
1140
+ }
1141
+
1142
+ subResponses.push({
1143
+ id: sub.id,
1144
+ status: 'active',
1145
+ scopes: effectiveScopes,
1146
+ bootstrap: false,
1147
+ nextCursor,
1148
+ commits,
1149
+ });
1150
+ nextCursors.push(nextCursor);
1151
+ continue;
1152
+ }
1033
1153
 
1034
- // Move row keys to insertion tail so Map iteration yields
1035
- // "latest change wins" order without a full array sort.
1036
- if (latestByRowKey.has(rowKey)) {
1037
- latestByRowKey.delete(rowKey);
1038
- }
1039
- latestByRowKey.set(rowKey, {
1040
- commitSeq: r.commit_seq,
1041
- createdAt: r.created_at,
1042
- actorId: r.actor_id,
1043
- change,
1044
- });
1045
- }
1154
+ const commitsBySeq = new Map<number, SyncCommit>();
1155
+ const commitSeqs: number[] = [];
1156
+
1157
+ for (const r of incrementalRows) {
1158
+ nextCursor = Math.max(nextCursor, r.commit_seq);
1159
+ const seq = r.commit_seq;
1160
+ let commit = commitsBySeq.get(seq);
1161
+ if (!commit) {
1162
+ commit = {
1163
+ commitSeq: seq,
1164
+ createdAt: r.created_at,
1165
+ actorId: r.actor_id,
1166
+ changes: [],
1167
+ };
1168
+ commitsBySeq.set(seq, commit);
1169
+ commitSeqs.push(seq);
1170
+ }
1171
+
1172
+ const change: SyncChange = {
1173
+ table: r.table,
1174
+ row_id: r.row_id,
1175
+ op: r.op,
1176
+ row_json: r.row_json,
1177
+ row_version: r.row_version,
1178
+ scopes: r.scopes,
1179
+ };
1180
+ commit.changes.push(change);
1181
+ }
1046
1182
 
1047
- nextCursor = Math.max(nextCursor, maxScannedCommitSeq);
1183
+ nextCursor = Math.max(nextCursor, maxScannedCommitSeq);
1048
1184
 
1049
- if (latestByRowKey.size === 0) {
1050
- subResponses.push({
1051
- id: sub.id,
1052
- status: 'active',
1053
- scopes: effectiveScopes,
1054
- bootstrap: false,
1055
- nextCursor,
1056
- commits: [],
1057
- });
1058
- nextCursors.push(nextCursor);
1059
- continue;
1060
- }
1185
+ if (commitSeqs.length === 0) {
1186
+ subResponses.push({
1187
+ id: sub.id,
1188
+ status: 'active',
1189
+ scopes: effectiveScopes,
1190
+ bootstrap: false,
1191
+ nextCursor,
1192
+ commits: [],
1193
+ });
1194
+ nextCursors.push(nextCursor);
1195
+ continue;
1196
+ }
1061
1197
 
1062
- const commits: SyncCommit[] = [];
1063
- for (const item of latestByRowKey.values()) {
1064
- const lastCommit = commits[commits.length - 1];
1065
- if (!lastCommit || lastCommit.commitSeq !== item.commitSeq) {
1066
- commits.push({
1067
- commitSeq: item.commitSeq,
1068
- createdAt: item.createdAt,
1069
- actorId: item.actorId,
1070
- changes: [item.change],
1198
+ const commits: SyncCommit[] = commitSeqs
1199
+ .map((seq) => commitsBySeq.get(seq))
1200
+ .filter((c): c is SyncCommit => !!c)
1201
+ .filter((c) => c.changes.length > 0);
1202
+
1203
+ subResponses.push({
1204
+ id: sub.id,
1205
+ status: 'active',
1206
+ scopes: effectiveScopes,
1207
+ bootstrap: false,
1208
+ nextCursor,
1209
+ commits,
1071
1210
  });
1072
- continue;
1211
+ nextCursors.push(nextCursor);
1073
1212
  }
1074
- lastCommit.changes.push(item.change);
1075
- }
1076
1213
 
1077
- subResponses.push({
1078
- id: sub.id,
1079
- status: 'active',
1080
- scopes: effectiveScopes,
1081
- bootstrap: false,
1082
- nextCursor,
1083
- commits,
1084
- });
1085
- nextCursors.push(nextCursor);
1086
- continue;
1087
- }
1088
-
1089
- const commitsBySeq = new Map<number, SyncCommit>();
1090
- const commitSeqs: number[] = [];
1091
-
1092
- for await (const r of dialect.iterateIncrementalPullRows(trx, {
1093
- partitionId,
1094
- table: sub.table,
1095
- scopes: effectiveScopes,
1096
- cursor,
1097
- limitCommits,
1098
- })) {
1099
- nextCursor = Math.max(nextCursor, r.commit_seq);
1100
- const seq = r.commit_seq;
1101
- let commit = commitsBySeq.get(seq);
1102
- if (!commit) {
1103
- commit = {
1104
- commitSeq: seq,
1105
- createdAt: r.created_at,
1106
- actorId: r.actor_id,
1107
- changes: [],
1214
+ const effectiveScopes = mergeScopes(activeSubscriptions);
1215
+ const clientCursor =
1216
+ nextCursors.length > 0
1217
+ ? Math.min(...nextCursors)
1218
+ : maxCommitSeq;
1219
+
1220
+ return {
1221
+ response: {
1222
+ ok: true as const,
1223
+ subscriptions: subResponses,
1224
+ },
1225
+ effectiveScopes,
1226
+ clientCursor,
1108
1227
  };
1109
- commitsBySeq.set(seq, commit);
1110
- commitSeqs.push(seq);
1111
1228
  }
1229
+ );
1112
1230
 
1113
- const change: SyncChange = {
1114
- table: r.table,
1115
- row_id: r.row_id,
1116
- op: r.op,
1117
- row_json: r.row_json,
1118
- row_version: r.row_version,
1119
- scopes: r.scopes,
1120
- };
1121
- commit.changes.push(change);
1122
- }
1123
-
1124
- nextCursor = Math.max(nextCursor, maxScannedCommitSeq);
1125
-
1126
- if (commitSeqs.length === 0) {
1127
- subResponses.push({
1128
- id: sub.id,
1129
- status: 'active',
1130
- scopes: effectiveScopes,
1131
- bootstrap: false,
1132
- nextCursor,
1133
- commits: [],
1134
- });
1135
- nextCursors.push(nextCursor);
1136
- continue;
1137
- }
1138
-
1139
- const commits: SyncCommit[] = commitSeqs
1140
- .map((seq) => commitsBySeq.get(seq))
1141
- .filter((c): c is SyncCommit => !!c)
1142
- .filter((c) => c.changes.length > 0);
1143
-
1144
- subResponses.push({
1145
- id: sub.id,
1146
- status: 'active',
1147
- scopes: effectiveScopes,
1148
- bootstrap: false,
1149
- nextCursor,
1150
- commits,
1151
- });
1152
- nextCursors.push(nextCursor);
1153
- }
1154
-
1155
- const effectiveScopes = mergeScopes(activeSubscriptions);
1156
- const clientCursor =
1157
- nextCursors.length > 0 ? Math.min(...nextCursors) : maxCommitSeq;
1158
-
1159
- return {
1160
- response: {
1161
- ok: true as const,
1162
- subscriptions: subResponses,
1163
- },
1164
- effectiveScopes,
1165
- clientCursor,
1166
- };
1167
- });
1168
-
1169
- const chunkStorage = args.chunkStorage;
1170
- if (chunkStorage && pendingExternalChunkWrites.length > 0) {
1171
- await runWithConcurrency(
1172
- pendingExternalChunkWrites,
1173
- 4,
1174
- async (pending) => {
1175
- const cacheLookupStartedAt = Date.now();
1176
- let chunkRef = await readSnapshotChunkRefByPageKey(db, {
1177
- partitionId: pending.cacheLookup.partitionId,
1178
- scopeKey: pending.cacheLookup.scopeKey,
1179
- scope: pending.cacheLookup.scope,
1180
- asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
1181
- rowCursor: pending.cacheLookup.rowCursor,
1182
- rowLimit: pending.cacheLookup.rowLimit,
1183
- encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
1184
- compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
1185
- });
1186
- bootstrapTimings.chunkCacheLookupMs += Math.max(
1187
- 0,
1188
- Date.now() - cacheLookupStartedAt
1189
- );
1190
-
1191
- if (!chunkRef) {
1192
- if (chunkStorage.storeChunkStream) {
1193
- const {
1194
- stream: bodyStream,
1195
- byteLength,
1196
- sha256,
1197
- gzipMs,
1198
- hashMs,
1199
- } = await encodeCompressedSnapshotChunkToStream(
1200
- pending.rowFrameParts
1201
- );
1202
- bootstrapTimings.chunkGzipMs += gzipMs;
1203
- bootstrapTimings.chunkHashMs += hashMs;
1204
- const chunkPersistStartedAt = Date.now();
1205
- chunkRef = await chunkStorage.storeChunkStream({
1231
+ const chunkStorage = args.chunkStorage;
1232
+ if (chunkStorage && pendingExternalChunkWrites.length > 0) {
1233
+ await runWithConcurrency(
1234
+ pendingExternalChunkWrites,
1235
+ 4,
1236
+ async (pending) => {
1237
+ const cacheLookupStartedAt = Date.now();
1238
+ let chunkRef = await readSnapshotChunkRefByPageKey(db, {
1206
1239
  partitionId: pending.cacheLookup.partitionId,
1207
1240
  scopeKey: pending.cacheLookup.scopeKey,
1208
1241
  scope: pending.cacheLookup.scope,
@@ -1211,77 +1244,121 @@ export async function pull<
1211
1244
  rowLimit: pending.cacheLookup.rowLimit,
1212
1245
  encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
1213
1246
  compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
1214
- sha256,
1215
- byteLength,
1216
- bodyStream,
1217
- expiresAt: pending.expiresAt,
1218
1247
  });
1219
- bootstrapTimings.chunkPersistMs += Math.max(
1248
+ bootstrapTimings.chunkCacheLookupMs += Math.max(
1220
1249
  0,
1221
- Date.now() - chunkPersistStartedAt
1222
- );
1223
- } else {
1224
- const encodedChunk = await encodeCompressedSnapshotChunk(
1225
- pending.rowFrameParts
1250
+ Date.now() - cacheLookupStartedAt
1226
1251
  );
1227
- bootstrapTimings.chunkGzipMs += encodedChunk.gzipMs;
1228
- bootstrapTimings.chunkHashMs += encodedChunk.hashMs;
1229
- const chunkPersistStartedAt = Date.now();
1230
- chunkRef = await chunkStorage.storeChunk({
1231
- partitionId: pending.cacheLookup.partitionId,
1232
- scopeKey: pending.cacheLookup.scopeKey,
1233
- scope: pending.cacheLookup.scope,
1234
- asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
1235
- rowCursor: pending.cacheLookup.rowCursor,
1236
- rowLimit: pending.cacheLookup.rowLimit,
1237
- encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
1238
- compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
1239
- sha256: encodedChunk.sha256,
1240
- body: encodedChunk.body,
1241
- expiresAt: pending.expiresAt,
1242
- });
1243
- bootstrapTimings.chunkPersistMs += Math.max(
1244
- 0,
1245
- Date.now() - chunkPersistStartedAt
1246
- );
1247
- }
1248
- }
1249
1252
 
1250
- pending.snapshot.chunks = [chunkRef];
1253
+ if (!chunkRef) {
1254
+ if (chunkStorage.storeChunkStream) {
1255
+ const {
1256
+ stream: bodyStream,
1257
+ byteLength,
1258
+ sha256,
1259
+ gzipMs,
1260
+ hashMs,
1261
+ } = await encodeCompressedSnapshotChunkToStream(
1262
+ pending.rowFrameParts
1263
+ );
1264
+ bootstrapTimings.chunkGzipMs += gzipMs;
1265
+ bootstrapTimings.chunkHashMs += hashMs;
1266
+ const chunkPersistStartedAt = Date.now();
1267
+ chunkRef = await chunkStorage.storeChunkStream({
1268
+ partitionId: pending.cacheLookup.partitionId,
1269
+ scopeKey: pending.cacheLookup.scopeKey,
1270
+ scope: pending.cacheLookup.scope,
1271
+ asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
1272
+ rowCursor: pending.cacheLookup.rowCursor,
1273
+ rowLimit: pending.cacheLookup.rowLimit,
1274
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
1275
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
1276
+ sha256,
1277
+ byteLength,
1278
+ bodyStream,
1279
+ expiresAt: pending.expiresAt,
1280
+ });
1281
+ bootstrapTimings.chunkPersistMs += Math.max(
1282
+ 0,
1283
+ Date.now() - chunkPersistStartedAt
1284
+ );
1285
+ } else {
1286
+ const encodedChunk = await encodeCompressedSnapshotChunk(
1287
+ pending.rowFrameParts
1288
+ );
1289
+ bootstrapTimings.chunkGzipMs += encodedChunk.gzipMs;
1290
+ bootstrapTimings.chunkHashMs += encodedChunk.hashMs;
1291
+ const chunkPersistStartedAt = Date.now();
1292
+ chunkRef = await chunkStorage.storeChunk({
1293
+ partitionId: pending.cacheLookup.partitionId,
1294
+ scopeKey: pending.cacheLookup.scopeKey,
1295
+ scope: pending.cacheLookup.scope,
1296
+ asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
1297
+ rowCursor: pending.cacheLookup.rowCursor,
1298
+ rowLimit: pending.cacheLookup.rowLimit,
1299
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
1300
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
1301
+ sha256: encodedChunk.sha256,
1302
+ body: encodedChunk.body,
1303
+ expiresAt: pending.expiresAt,
1304
+ });
1305
+ bootstrapTimings.chunkPersistMs += Math.max(
1306
+ 0,
1307
+ Date.now() - chunkPersistStartedAt
1308
+ );
1309
+ }
1310
+ }
1311
+
1312
+ pending.snapshot.chunks = [chunkRef];
1313
+ }
1314
+ );
1251
1315
  }
1252
- );
1253
- }
1254
1316
 
1255
- const durationMs = Math.max(0, Date.now() - startedAtMs);
1256
- const stats = summarizePullResponse(result.response);
1317
+ const durationMs = Math.max(0, Date.now() - startedAtMs);
1318
+ const stats = summarizePullResponse(result.response);
1319
+
1320
+ span.setAttribute('status', 'ok');
1321
+ span.setAttribute('duration_ms', durationMs);
1322
+ span.setAttribute('subscription_count', stats.subscriptionCount);
1323
+ span.setAttribute('commit_count', stats.commitCount);
1324
+ span.setAttribute('change_count', stats.changeCount);
1325
+ span.setAttribute('snapshot_page_count', stats.snapshotPageCount);
1326
+ span.setAttributes({
1327
+ bootstrap_snapshot_query_ms: bootstrapTimings.snapshotQueryMs,
1328
+ bootstrap_row_frame_encode_ms: bootstrapTimings.rowFrameEncodeMs,
1329
+ bootstrap_chunk_cache_lookup_ms:
1330
+ bootstrapTimings.chunkCacheLookupMs,
1331
+ bootstrap_chunk_gzip_ms: bootstrapTimings.chunkGzipMs,
1332
+ bootstrap_chunk_hash_ms: bootstrapTimings.chunkHashMs,
1333
+ bootstrap_chunk_persist_ms: bootstrapTimings.chunkPersistMs,
1334
+ });
1335
+ span.setStatus('ok');
1257
1336
 
1258
- span.setAttribute('status', 'ok');
1259
- span.setAttribute('duration_ms', durationMs);
1260
- span.setAttribute('subscription_count', stats.subscriptionCount);
1261
- span.setAttribute('commit_count', stats.commitCount);
1262
- span.setAttribute('change_count', stats.changeCount);
1263
- span.setAttribute('snapshot_page_count', stats.snapshotPageCount);
1264
- span.setAttributes({
1265
- bootstrap_snapshot_query_ms: bootstrapTimings.snapshotQueryMs,
1266
- bootstrap_row_frame_encode_ms: bootstrapTimings.rowFrameEncodeMs,
1267
- bootstrap_chunk_cache_lookup_ms: bootstrapTimings.chunkCacheLookupMs,
1268
- bootstrap_chunk_gzip_ms: bootstrapTimings.chunkGzipMs,
1269
- bootstrap_chunk_hash_ms: bootstrapTimings.chunkHashMs,
1270
- bootstrap_chunk_persist_ms: bootstrapTimings.chunkPersistMs,
1271
- });
1272
- span.setStatus('ok');
1337
+ recordPullMetrics({
1338
+ status: 'ok',
1339
+ dedupeRows,
1340
+ durationMs,
1341
+ stats,
1342
+ });
1273
1343
 
1274
- recordPullMetrics({
1275
- status: 'ok',
1276
- dedupeRows,
1277
- durationMs,
1278
- stats,
1279
- });
1344
+ return {
1345
+ ...result,
1346
+ bootstrapTimings,
1347
+ };
1348
+ } catch (error) {
1349
+ if (
1350
+ error instanceof Error &&
1351
+ attemptIndex < MAX_PULL_TRANSACTION_RETRIES - 1 &&
1352
+ isSerializablePullError(error)
1353
+ ) {
1354
+ await delay(PULL_TRANSACTION_RETRY_DELAY_MS * (attemptIndex + 1));
1355
+ continue;
1356
+ }
1357
+ throw error;
1358
+ }
1359
+ }
1280
1360
 
1281
- return {
1282
- ...result,
1283
- bootstrapTimings,
1284
- };
1361
+ throw new Error('Pull transaction retry loop exhausted unexpectedly');
1285
1362
  } catch (error) {
1286
1363
  const durationMs = Math.max(0, Date.now() - startedAtMs);
1287
1364