@syncular/server 0.0.6-185 → 0.0.6-201

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
@@ -41,7 +41,37 @@ import {
41
41
  import { resolveEffectiveScopesForSubscriptions } from './subscriptions/resolve';
42
42
 
43
43
  const defaultScopeCache = createMemoryScopeCache();
44
- const MAX_SNAPSHOT_BUNDLE_ROW_FRAME_BYTES = 512 * 1024;
44
+ const DEFAULT_MAX_SNAPSHOT_BUNDLE_ROW_FRAME_BYTES = 512 * 1024;
45
+ const MAX_ADAPTIVE_SNAPSHOT_BUNDLE_ROW_FRAME_BYTES = 4 * 1024 * 1024;
46
+ const DEFAULT_INLINE_SNAPSHOT_ROW_FRAME_BYTES = 256 * 1024;
47
+ const EMPTY_SNAPSHOT_ROW_FRAMES = encodeSnapshotRows([]);
48
+
49
+ interface PullBootstrapTimings {
50
+ snapshotQueryMs: number;
51
+ rowFrameEncodeMs: number;
52
+ chunkCacheLookupMs: number;
53
+ chunkGzipMs: number;
54
+ chunkHashMs: number;
55
+ chunkPersistMs: number;
56
+ }
57
+
58
+ interface SnapshotChunkEncodeResult {
59
+ body: Uint8Array;
60
+ sha256: string;
61
+ gzipMs: number;
62
+ hashMs: number;
63
+ }
64
+
65
+ function createPullBootstrapTimings(): PullBootstrapTimings {
66
+ return {
67
+ snapshotQueryMs: 0,
68
+ rowFrameEncodeMs: 0,
69
+ chunkCacheLookupMs: 0,
70
+ chunkGzipMs: 0,
71
+ chunkHashMs: 0,
72
+ chunkPersistMs: 0,
73
+ };
74
+ }
45
75
 
46
76
  function concatByteChunks(chunks: readonly Uint8Array[]): Uint8Array {
47
77
  if (chunks.length === 1) {
@@ -186,28 +216,73 @@ async function gzipByteChunks(
186
216
  return gzipBytes(concatByteChunks(chunks));
187
217
  }
188
218
 
189
- async function gzipByteChunksToStream(chunks: readonly Uint8Array[]): Promise<{
219
+ async function encodeCompressedSnapshotChunk(
220
+ chunks: readonly Uint8Array[]
221
+ ): Promise<SnapshotChunkEncodeResult> {
222
+ const gzipStartedAt = Date.now();
223
+ const gzipPromise = gzipByteChunks(chunks).then((body) => ({
224
+ body,
225
+ gzipMs: Math.max(0, Date.now() - gzipStartedAt),
226
+ }));
227
+ const hashStartedAt = Date.now();
228
+ const hashPromise = sha256HexFromByteChunks(chunks).then((sha256) => ({
229
+ sha256,
230
+ hashMs: Math.max(0, Date.now() - hashStartedAt),
231
+ }));
232
+ const [{ body, gzipMs }, { sha256, hashMs }] = await Promise.all([
233
+ gzipPromise,
234
+ hashPromise,
235
+ ]);
236
+ return { body, sha256, gzipMs, hashMs };
237
+ }
238
+
239
+ async function encodeCompressedSnapshotChunkToStream(
240
+ chunks: readonly Uint8Array[]
241
+ ): Promise<{
190
242
  stream: ReadableStream<Uint8Array>;
191
- byteLength?: number;
243
+ byteLength: number;
244
+ sha256: string;
245
+ gzipMs: number;
246
+ hashMs: number;
192
247
  }> {
193
- if (typeof CompressionStream !== 'undefined') {
194
- const source = byteChunksToStream(chunks).pipeThrough(
195
- new CompressionStream('gzip')
196
- );
197
- return {
198
- stream: bufferSourceStreamToUint8ArrayStream(source),
199
- };
200
- }
201
-
202
- const compressed = await gzipBytes(concatByteChunks(chunks));
248
+ const encoded = await encodeCompressedSnapshotChunk(chunks);
203
249
  return {
204
250
  stream: bufferSourceStreamToUint8ArrayStream(
205
- byteChunksToStream([compressed])
251
+ byteChunksToStream([encoded.body])
206
252
  ),
207
- byteLength: compressed.length,
253
+ byteLength: encoded.body.length,
254
+ sha256: encoded.sha256,
255
+ gzipMs: encoded.gzipMs,
256
+ hashMs: encoded.hashMs,
208
257
  };
209
258
  }
210
259
 
260
+ function resolveSnapshotBundleMaxBytes(args: {
261
+ configuredMaxBytes?: number;
262
+ pageRowCount: number;
263
+ pageRowFrameBytes: number;
264
+ }): number {
265
+ if (
266
+ typeof args.configuredMaxBytes === 'number' &&
267
+ Number.isFinite(args.configuredMaxBytes) &&
268
+ args.configuredMaxBytes > 0
269
+ ) {
270
+ return Math.max(1, args.configuredMaxBytes);
271
+ }
272
+
273
+ if (args.pageRowCount <= 0 || args.pageRowFrameBytes <= 0) {
274
+ return DEFAULT_MAX_SNAPSHOT_BUNDLE_ROW_FRAME_BYTES;
275
+ }
276
+
277
+ return Math.max(
278
+ DEFAULT_MAX_SNAPSHOT_BUNDLE_ROW_FRAME_BYTES,
279
+ Math.min(
280
+ MAX_ADAPTIVE_SNAPSHOT_BUNDLE_ROW_FRAME_BYTES,
281
+ args.pageRowFrameBytes
282
+ )
283
+ );
284
+ }
285
+
211
286
  export interface PullResult {
212
287
  response: SyncPullResponse;
213
288
  /**
@@ -217,6 +292,8 @@ export interface PullResult {
217
292
  effectiveScopes: ScopeValues;
218
293
  /** Minimum nextCursor across active subscriptions (for pruning cursor tracking). */
219
294
  clientCursor: number;
295
+ /** Internal bootstrap timing breakdown used for benchmark-gated diagnostics. */
296
+ bootstrapTimings?: PullBootstrapTimings;
220
297
  }
221
298
 
222
299
  interface PendingExternalChunkWrite {
@@ -419,6 +496,7 @@ async function readLatestExternalCommitByTable<DB extends SyncCoreDb>(
419
496
  .select((eb) => eb.fn.max('tc.commit_seq').as('latest_commit_seq'))
420
497
  .where('tc.partition_id', '=', args.partitionId)
421
498
  .where('cm.client_id', '=', EXTERNAL_CLIENT_ID)
499
+ .where('cm.change_count', '=', 0)
422
500
  .where('tc.commit_seq', '>', args.afterCursor)
423
501
  .where('tc.table', 'in', tableNames)
424
502
  .groupBy('tc.table')
@@ -480,7 +558,7 @@ export async function pull<
480
558
  request.limitSnapshotRows,
481
559
  1000,
482
560
  1,
483
- 5000
561
+ 20000
484
562
  );
485
563
  const maxSnapshotPages = sanitizeLimit(
486
564
  request.maxSnapshotPages,
@@ -490,6 +568,7 @@ export async function pull<
490
568
  );
491
569
  const dedupeRows = request.dedupeRows === true;
492
570
  const pendingExternalChunkWrites: PendingExternalChunkWrite[] = [];
571
+ const bootstrapTimings = createPullBootstrapTimings();
493
572
 
494
573
  // Resolve effective scopes for each subscription
495
574
  const resolved = await resolveEffectiveScopesForSubscriptions({
@@ -581,6 +660,11 @@ export async function pull<
581
660
  args.handlers,
582
661
  sub.table
583
662
  ).map((handler) => handler.table);
663
+ const preferInlineBootstrapSnapshot =
664
+ cursor >= 0 ||
665
+ sub.bootstrapState != null ||
666
+ (latestExternalCommitForTable !== undefined &&
667
+ latestExternalCommitForTable > cursor);
584
668
 
585
669
  const initState: SyncBootstrapState = {
586
670
  asOfCommitSeq: maxCommitSeq,
@@ -636,17 +720,29 @@ export async function pull<
636
720
  ttlMs: number;
637
721
  rowFrameByteLength: number;
638
722
  rowFrameParts: Uint8Array[];
723
+ inlineRows: unknown[] | null;
639
724
  }
640
725
 
641
726
  const flushSnapshotBundle = async (
642
727
  bundle: SnapshotBundle
643
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;
737
+ }
738
+
644
739
  const nowIso = new Date().toISOString();
645
740
  const bundleRowLimit = Math.max(
646
741
  1,
647
742
  limitSnapshotRows * bundle.pageCount
648
743
  );
649
744
 
745
+ const cacheLookupStartedAt = Date.now();
650
746
  const cached = await readSnapshotChunkRefByPageKey(trx, {
651
747
  partitionId,
652
748
  scopeKey: cacheKey,
@@ -658,6 +754,10 @@ export async function pull<
658
754
  compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
659
755
  nowIso,
660
756
  });
757
+ bootstrapTimings.chunkCacheLookupMs += Math.max(
758
+ 0,
759
+ Date.now() - cacheLookupStartedAt
760
+ );
661
761
 
662
762
  let chunkRef = cached;
663
763
  if (!chunkRef) {
@@ -689,13 +789,13 @@ export async function pull<
689
789
  });
690
790
  return;
691
791
  }
692
- const sha256 = await sha256HexFromByteChunks(
693
- bundle.rowFrameParts
694
- );
695
- const compressedBody = await gzipByteChunks(
792
+ const encodedChunk = await encodeCompressedSnapshotChunk(
696
793
  bundle.rowFrameParts
697
794
  );
795
+ bootstrapTimings.chunkGzipMs += encodedChunk.gzipMs;
796
+ bootstrapTimings.chunkHashMs += encodedChunk.hashMs;
698
797
  const chunkId = randomId();
798
+ const chunkPersistStartedAt = Date.now();
699
799
  chunkRef = await insertSnapshotChunk(trx, {
700
800
  chunkId,
701
801
  partitionId,
@@ -706,10 +806,14 @@ export async function pull<
706
806
  rowLimit: bundleRowLimit,
707
807
  encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
708
808
  compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
709
- sha256,
710
- body: compressedBody,
809
+ sha256: encodedChunk.sha256,
810
+ body: encodedChunk.body,
711
811
  expiresAt,
712
812
  });
813
+ bootstrapTimings.chunkPersistMs += Math.max(
814
+ 0,
815
+ Date.now() - chunkPersistStartedAt
816
+ );
713
817
  }
714
818
 
715
819
  snapshots.push({
@@ -750,7 +854,7 @@ export async function pull<
750
854
  if (activeBundle) {
751
855
  await flushSnapshotBundle(activeBundle);
752
856
  }
753
- const bundleHeader = encodeSnapshotRows([]);
857
+ const bundleHeader = EMPTY_SNAPSHOT_ROW_FRAMES;
754
858
  activeBundle = {
755
859
  table: nextTableName,
756
860
  startCursor: nextState.rowCursor,
@@ -761,9 +865,11 @@ export async function pull<
761
865
  tableHandler.snapshotChunkTtlMs ?? 24 * 60 * 60 * 1000,
762
866
  rowFrameByteLength: bundleHeader.length,
763
867
  rowFrameParts: [bundleHeader],
868
+ inlineRows: null,
764
869
  };
765
870
  }
766
871
 
872
+ const snapshotQueryStartedAt = Date.now();
767
873
  const page: { rows: unknown[]; nextCursor: string | null } =
768
874
  await tableHandler.snapshot(
769
875
  {
@@ -776,15 +882,29 @@ export async function pull<
776
882
  },
777
883
  sub.params
778
884
  );
885
+ bootstrapTimings.snapshotQueryMs += Math.max(
886
+ 0,
887
+ Date.now() - snapshotQueryStartedAt
888
+ );
779
889
 
890
+ const rowFrameEncodeStartedAt = Date.now();
780
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
+ });
781
901
  if (
782
902
  activeBundle.pageCount > 0 &&
783
903
  activeBundle.rowFrameByteLength + rowFrames.length >
784
- MAX_SNAPSHOT_BUNDLE_ROW_FRAME_BYTES
904
+ bundleMaxBytes
785
905
  ) {
786
906
  await flushSnapshotBundle(activeBundle);
787
- const bundleHeader = encodeSnapshotRows([]);
907
+ const bundleHeader = EMPTY_SNAPSHOT_ROW_FRAMES;
788
908
  activeBundle = {
789
909
  table: nextTableName,
790
910
  startCursor: nextState.rowCursor,
@@ -795,8 +915,20 @@ export async function pull<
795
915
  tableHandler.snapshotChunkTtlMs ?? 24 * 60 * 60 * 1000,
796
916
  rowFrameByteLength: bundleHeader.length,
797
917
  rowFrameParts: [bundleHeader],
918
+ inlineRows: null,
798
919
  };
799
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
+ }
800
932
  activeBundle.rowFrameParts.push(rowFrames);
801
933
  activeBundle.rowFrameByteLength += rowFrames.length;
802
934
  activeBundle.pageCount += 1;
@@ -1040,6 +1172,7 @@ export async function pull<
1040
1172
  pendingExternalChunkWrites,
1041
1173
  4,
1042
1174
  async (pending) => {
1175
+ const cacheLookupStartedAt = Date.now();
1043
1176
  let chunkRef = await readSnapshotChunkRefByPageKey(db, {
1044
1177
  partitionId: pending.cacheLookup.partitionId,
1045
1178
  scopeKey: pending.cacheLookup.scopeKey,
@@ -1050,14 +1183,25 @@ export async function pull<
1050
1183
  encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
1051
1184
  compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
1052
1185
  });
1186
+ bootstrapTimings.chunkCacheLookupMs += Math.max(
1187
+ 0,
1188
+ Date.now() - cacheLookupStartedAt
1189
+ );
1053
1190
 
1054
1191
  if (!chunkRef) {
1055
- const sha256 = await sha256HexFromByteChunks(
1056
- pending.rowFrameParts
1057
- );
1058
1192
  if (chunkStorage.storeChunkStream) {
1059
- const { stream: bodyStream, byteLength } =
1060
- await gzipByteChunksToStream(pending.rowFrameParts);
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();
1061
1205
  chunkRef = await chunkStorage.storeChunkStream({
1062
1206
  partitionId: pending.cacheLookup.partitionId,
1063
1207
  scopeKey: pending.cacheLookup.scopeKey,
@@ -1072,10 +1216,17 @@ export async function pull<
1072
1216
  bodyStream,
1073
1217
  expiresAt: pending.expiresAt,
1074
1218
  });
1219
+ bootstrapTimings.chunkPersistMs += Math.max(
1220
+ 0,
1221
+ Date.now() - chunkPersistStartedAt
1222
+ );
1075
1223
  } else {
1076
- const compressedBody = await gzipByteChunks(
1224
+ const encodedChunk = await encodeCompressedSnapshotChunk(
1077
1225
  pending.rowFrameParts
1078
1226
  );
1227
+ bootstrapTimings.chunkGzipMs += encodedChunk.gzipMs;
1228
+ bootstrapTimings.chunkHashMs += encodedChunk.hashMs;
1229
+ const chunkPersistStartedAt = Date.now();
1079
1230
  chunkRef = await chunkStorage.storeChunk({
1080
1231
  partitionId: pending.cacheLookup.partitionId,
1081
1232
  scopeKey: pending.cacheLookup.scopeKey,
@@ -1085,10 +1236,14 @@ export async function pull<
1085
1236
  rowLimit: pending.cacheLookup.rowLimit,
1086
1237
  encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
1087
1238
  compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
1088
- sha256,
1089
- body: compressedBody,
1239
+ sha256: encodedChunk.sha256,
1240
+ body: encodedChunk.body,
1090
1241
  expiresAt: pending.expiresAt,
1091
1242
  });
1243
+ bootstrapTimings.chunkPersistMs += Math.max(
1244
+ 0,
1245
+ Date.now() - chunkPersistStartedAt
1246
+ );
1092
1247
  }
1093
1248
  }
1094
1249
 
@@ -1106,6 +1261,14 @@ export async function pull<
1106
1261
  span.setAttribute('commit_count', stats.commitCount);
1107
1262
  span.setAttribute('change_count', stats.changeCount);
1108
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
+ });
1109
1272
  span.setStatus('ok');
1110
1273
 
1111
1274
  recordPullMetrics({
@@ -1115,7 +1278,10 @@ export async function pull<
1115
1278
  stats,
1116
1279
  });
1117
1280
 
1118
- return result;
1281
+ return {
1282
+ ...result,
1283
+ bootstrapTimings,
1284
+ };
1119
1285
  } catch (error) {
1120
1286
  const durationMs = Math.max(0, Date.now() - startedAtMs);
1121
1287