@powersync/service-core 0.0.0-dev-20250122110924 → 0.0.0-dev-20250227082606

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.
Files changed (193) hide show
  1. package/CHANGELOG.md +84 -9
  2. package/dist/api/RouteAPI.d.ts +9 -1
  3. package/dist/api/diagnostics.js +107 -169
  4. package/dist/api/diagnostics.js.map +1 -1
  5. package/dist/auth/CachedKeyCollector.js +26 -25
  6. package/dist/auth/CachedKeyCollector.js.map +1 -1
  7. package/dist/auth/CompoundKeyCollector.js +1 -0
  8. package/dist/auth/CompoundKeyCollector.js.map +1 -1
  9. package/dist/auth/KeySpec.js +3 -0
  10. package/dist/auth/KeySpec.js.map +1 -1
  11. package/dist/auth/KeyStore.js +4 -0
  12. package/dist/auth/KeyStore.js.map +1 -1
  13. package/dist/auth/LeakyBucket.js +5 -0
  14. package/dist/auth/LeakyBucket.js.map +1 -1
  15. package/dist/auth/RemoteJWKSCollector.js +4 -1
  16. package/dist/auth/RemoteJWKSCollector.js.map +1 -1
  17. package/dist/auth/StaticKeyCollector.js +1 -0
  18. package/dist/auth/StaticKeyCollector.js.map +1 -1
  19. package/dist/auth/StaticSupabaseKeyCollector.js +1 -0
  20. package/dist/auth/StaticSupabaseKeyCollector.js.map +1 -1
  21. package/dist/entry/commands/compact-action.js +10 -73
  22. package/dist/entry/commands/compact-action.js.map +1 -1
  23. package/dist/metrics/Metrics.js +35 -1
  24. package/dist/metrics/Metrics.js.map +1 -1
  25. package/dist/modules/AbstractModule.d.ts +1 -1
  26. package/dist/modules/AbstractModule.js +2 -0
  27. package/dist/modules/AbstractModule.js.map +1 -1
  28. package/dist/modules/ModuleManager.js +1 -3
  29. package/dist/modules/ModuleManager.js.map +1 -1
  30. package/dist/replication/AbstractReplicationJob.js +4 -2
  31. package/dist/replication/AbstractReplicationJob.js.map +1 -1
  32. package/dist/replication/AbstractReplicator.js +26 -88
  33. package/dist/replication/AbstractReplicator.js.map +1 -1
  34. package/dist/replication/ReplicationEngine.js +1 -3
  35. package/dist/replication/ReplicationEngine.js.map +1 -1
  36. package/dist/replication/ReplicationModule.js +3 -0
  37. package/dist/replication/ReplicationModule.js.map +1 -1
  38. package/dist/routes/RouterEngine.js +8 -0
  39. package/dist/routes/RouterEngine.js.map +1 -1
  40. package/dist/routes/configure-fastify.d.ts +3 -3
  41. package/dist/routes/endpoints/admin.d.ts +6 -6
  42. package/dist/routes/endpoints/admin.js +4 -1
  43. package/dist/routes/endpoints/admin.js.map +1 -1
  44. package/dist/routes/endpoints/checkpointing.js +17 -86
  45. package/dist/routes/endpoints/checkpointing.js.map +1 -1
  46. package/dist/routes/endpoints/socket-route.js +7 -6
  47. package/dist/routes/endpoints/socket-route.js.map +1 -1
  48. package/dist/routes/endpoints/sync-rules.js +7 -2
  49. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  50. package/dist/routes/endpoints/sync-stream.js +7 -6
  51. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  52. package/dist/runner/teardown.js +5 -67
  53. package/dist/runner/teardown.js.map +1 -1
  54. package/dist/storage/BucketStorage.d.ts +8 -414
  55. package/dist/storage/BucketStorage.js +9 -7
  56. package/dist/storage/BucketStorage.js.map +1 -1
  57. package/dist/storage/BucketStorageBatch.d.ts +130 -0
  58. package/dist/storage/BucketStorageBatch.js +10 -0
  59. package/dist/storage/BucketStorageBatch.js.map +1 -0
  60. package/dist/storage/BucketStorageFactory.d.ts +145 -0
  61. package/dist/storage/BucketStorageFactory.js +2 -0
  62. package/dist/storage/BucketStorageFactory.js.map +1 -0
  63. package/dist/storage/ChecksumCache.js +12 -7
  64. package/dist/storage/ChecksumCache.js.map +1 -1
  65. package/dist/storage/PersistedSyncRulesContent.d.ts +20 -0
  66. package/dist/storage/PersistedSyncRulesContent.js +2 -0
  67. package/dist/storage/PersistedSyncRulesContent.js.map +1 -0
  68. package/dist/storage/ReplicationEventPayload.d.ts +1 -1
  69. package/dist/storage/ReplicationLock.d.ts +4 -0
  70. package/dist/storage/ReplicationLock.js +2 -0
  71. package/dist/storage/ReplicationLock.js.map +1 -0
  72. package/dist/storage/SourceEntity.d.ts +6 -2
  73. package/dist/storage/SourceTable.d.ts +2 -2
  74. package/dist/storage/SourceTable.js +32 -25
  75. package/dist/storage/SourceTable.js.map +1 -1
  76. package/dist/storage/StorageEngine.d.ts +4 -4
  77. package/dist/storage/StorageEngine.js +6 -5
  78. package/dist/storage/StorageEngine.js.map +1 -1
  79. package/dist/storage/StorageProvider.d.ts +4 -1
  80. package/dist/storage/SyncRulesBucketStorage.d.ts +207 -0
  81. package/dist/storage/SyncRulesBucketStorage.js +7 -0
  82. package/dist/storage/SyncRulesBucketStorage.js.map +1 -0
  83. package/dist/storage/bson.d.ts +19 -6
  84. package/dist/storage/bson.js +18 -2
  85. package/dist/storage/bson.js.map +1 -1
  86. package/dist/storage/storage-index.d.ts +5 -0
  87. package/dist/storage/storage-index.js +5 -0
  88. package/dist/storage/storage-index.js.map +1 -1
  89. package/dist/sync/BroadcastIterable.js +4 -3
  90. package/dist/sync/BroadcastIterable.js.map +1 -1
  91. package/dist/sync/BucketChecksumState.d.ts +95 -0
  92. package/dist/sync/BucketChecksumState.js +321 -0
  93. package/dist/sync/BucketChecksumState.js.map +1 -0
  94. package/dist/sync/LastValueSink.js +2 -0
  95. package/dist/sync/LastValueSink.js.map +1 -1
  96. package/dist/sync/RequestTracker.js +2 -4
  97. package/dist/sync/RequestTracker.js.map +1 -1
  98. package/dist/sync/SyncContext.d.ts +17 -0
  99. package/dist/sync/SyncContext.js +23 -0
  100. package/dist/sync/SyncContext.js.map +1 -0
  101. package/dist/sync/merge.js +4 -0
  102. package/dist/sync/merge.js.map +1 -1
  103. package/dist/sync/sync-index.d.ts +2 -0
  104. package/dist/sync/sync-index.js +2 -0
  105. package/dist/sync/sync-index.js.map +1 -1
  106. package/dist/sync/sync.d.ts +10 -4
  107. package/dist/sync/sync.js +143 -149
  108. package/dist/sync/sync.js.map +1 -1
  109. package/dist/sync/util.d.ts +9 -0
  110. package/dist/sync/util.js +46 -2
  111. package/dist/sync/util.js.map +1 -1
  112. package/dist/system/ServiceContext.d.ts +3 -0
  113. package/dist/system/ServiceContext.js +10 -0
  114. package/dist/system/ServiceContext.js.map +1 -1
  115. package/dist/util/Mutex.js +5 -0
  116. package/dist/util/Mutex.js.map +1 -1
  117. package/dist/util/checkpointing.d.ts +13 -0
  118. package/dist/util/checkpointing.js +29 -0
  119. package/dist/util/checkpointing.js.map +1 -0
  120. package/dist/util/config/compound-config-collector.js +16 -2
  121. package/dist/util/config/compound-config-collector.js.map +1 -1
  122. package/dist/util/config/defaults.d.ts +5 -0
  123. package/dist/util/config/defaults.js +6 -0
  124. package/dist/util/config/defaults.js.map +1 -0
  125. package/dist/util/config/sync-rules/impl/base64-sync-rules-collector.js +1 -0
  126. package/dist/util/config/sync-rules/impl/base64-sync-rules-collector.js.map +1 -1
  127. package/dist/util/config/sync-rules/impl/filesystem-sync-rules-collector.js +1 -0
  128. package/dist/util/config/sync-rules/impl/filesystem-sync-rules-collector.js.map +1 -1
  129. package/dist/util/config/sync-rules/impl/inline-sync-rules-collector.js +1 -0
  130. package/dist/util/config/sync-rules/impl/inline-sync-rules-collector.js.map +1 -1
  131. package/dist/util/config/sync-rules/sync-rules-provider.d.ts +2 -0
  132. package/dist/util/config/sync-rules/sync-rules-provider.js +4 -0
  133. package/dist/util/config/sync-rules/sync-rules-provider.js.map +1 -1
  134. package/dist/util/config/types.d.ts +8 -2
  135. package/dist/util/config/types.js.map +1 -1
  136. package/dist/util/memory-tracking.js +1 -1
  137. package/dist/util/memory-tracking.js.map +1 -1
  138. package/dist/util/protocol-types.d.ts +13 -4
  139. package/dist/util/util-index.d.ts +1 -0
  140. package/dist/util/util-index.js +1 -0
  141. package/dist/util/util-index.js.map +1 -1
  142. package/dist/util/utils.d.ts +0 -1
  143. package/dist/util/utils.js +0 -9
  144. package/dist/util/utils.js.map +1 -1
  145. package/package.json +6 -6
  146. package/src/api/RouteAPI.ts +11 -1
  147. package/src/api/diagnostics.ts +1 -1
  148. package/src/entry/commands/compact-action.ts +2 -3
  149. package/src/modules/AbstractModule.ts +1 -1
  150. package/src/replication/AbstractReplicator.ts +16 -15
  151. package/src/routes/RouterEngine.ts +1 -0
  152. package/src/routes/endpoints/admin.ts +4 -1
  153. package/src/routes/endpoints/checkpointing.ts +11 -22
  154. package/src/routes/endpoints/socket-route.ts +9 -6
  155. package/src/routes/endpoints/sync-rules.ts +7 -2
  156. package/src/routes/endpoints/sync-stream.ts +10 -6
  157. package/src/runner/teardown.ts +1 -1
  158. package/src/storage/BucketStorage.ts +8 -515
  159. package/src/storage/BucketStorageBatch.ts +158 -0
  160. package/src/storage/BucketStorageFactory.ts +166 -0
  161. package/src/storage/ChecksumCache.ts +1 -0
  162. package/src/storage/PersistedSyncRulesContent.ts +26 -0
  163. package/src/storage/ReplicationEventPayload.ts +1 -1
  164. package/src/storage/ReplicationLock.ts +5 -0
  165. package/src/storage/SourceEntity.ts +6 -2
  166. package/src/storage/SourceTable.ts +1 -1
  167. package/src/storage/StorageEngine.ts +4 -4
  168. package/src/storage/StorageProvider.ts +4 -1
  169. package/src/storage/SyncRulesBucketStorage.ts +265 -0
  170. package/src/storage/bson.ts +31 -11
  171. package/src/storage/storage-index.ts +5 -0
  172. package/src/sync/BucketChecksumState.ts +418 -0
  173. package/src/sync/SyncContext.ts +36 -0
  174. package/src/sync/sync-index.ts +2 -0
  175. package/src/sync/sync.ts +199 -177
  176. package/src/sync/util.ts +54 -0
  177. package/src/system/ServiceContext.ts +9 -0
  178. package/src/util/checkpointing.ts +41 -0
  179. package/src/util/config/compound-config-collector.ts +26 -2
  180. package/src/util/config/defaults.ts +5 -0
  181. package/src/util/config/sync-rules/impl/base64-sync-rules-collector.ts +1 -0
  182. package/src/util/config/sync-rules/impl/filesystem-sync-rules-collector.ts +1 -0
  183. package/src/util/config/sync-rules/impl/inline-sync-rules-collector.ts +1 -0
  184. package/src/util/config/sync-rules/sync-rules-provider.ts +6 -0
  185. package/src/util/config/types.ts +9 -2
  186. package/src/util/memory-tracking.ts +2 -2
  187. package/src/util/protocol-types.ts +16 -4
  188. package/src/util/util-index.ts +1 -0
  189. package/src/util/utils.ts +0 -10
  190. package/test/src/auth.test.ts +5 -5
  191. package/test/src/sync/BucketChecksumState.test.ts +580 -0
  192. package/test/src/sync/util.test.ts +34 -0
  193. package/tsconfig.tsbuildinfo +1 -1
package/src/sync/sync.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { JSONBig, JsonContainer } from '@powersync/service-jsonbig';
2
- import { RequestParameters } from '@powersync/service-sync-rules';
3
- import { Semaphore, withTimeout } from 'async-mutex';
2
+ import { BucketDescription, BucketPriority, RequestParameters, SqlSyncRules } from '@powersync/service-sync-rules';
4
3
 
5
4
  import { AbortError } from 'ix/aborterror.js';
6
5
 
@@ -9,34 +8,19 @@ import * as storage from '../storage/storage-index.js';
9
8
  import * as util from '../util/util-index.js';
10
9
 
11
10
  import { logger } from '@powersync/lib-services-framework';
11
+ import { BucketChecksumState } from './BucketChecksumState.js';
12
12
  import { mergeAsyncIterables } from './merge.js';
13
+ import { acquireSemaphoreAbortable, settledPromise, tokenStream, TokenStreamOptions } from './util.js';
14
+ import { SyncContext } from './SyncContext.js';
13
15
  import { RequestTracker } from './RequestTracker.js';
14
- import { TokenStreamOptions, tokenStream } from './util.js';
15
-
16
- /**
17
- * Maximum number of connections actively fetching data.
18
- */
19
- const MAX_ACTIVE_CONNECTIONS = 10;
20
-
21
- /**
22
- * Maximum duration to wait for the mutex to become available.
23
- *
24
- * This gives an explicit error if there are mutex issues, rather than just hanging.
25
- */
26
- const MUTEX_ACQUIRE_TIMEOUT = 30_000;
27
-
28
- const syncSemaphore = withTimeout(
29
- new Semaphore(MAX_ACTIVE_CONNECTIONS),
30
- MUTEX_ACQUIRE_TIMEOUT,
31
- new Error(`Timeout while waiting for data`)
32
- );
33
16
 
34
17
  export interface SyncStreamParameters {
35
- storage: storage.BucketStorageFactory;
18
+ syncContext: SyncContext;
19
+ bucketStorage: storage.SyncRulesBucketStorage;
20
+ syncRules: SqlSyncRules;
36
21
  params: util.StreamingSyncRequest;
37
22
  syncParams: RequestParameters;
38
23
  token: auth.JwtPayload;
39
- parseOptions: storage.ParseSyncRulesOptions;
40
24
  /**
41
25
  * If this signal is aborted, the stream response ends as soon as possible, without error.
42
26
  */
@@ -49,7 +33,8 @@ export interface SyncStreamParameters {
49
33
  export async function* streamResponse(
50
34
  options: SyncStreamParameters
51
35
  ): AsyncIterable<util.StreamingSyncLine | string | null> {
52
- const { storage, params, syncParams, token, tokenStreamOptions, tracker, signal, parseOptions } = options;
36
+ const { syncContext, bucketStorage, syncRules, params, syncParams, token, tokenStreamOptions, tracker, signal } =
37
+ options;
53
38
  // We also need to be able to abort, so we create our own controller.
54
39
  const controller = new AbortController();
55
40
  if (signal) {
@@ -65,7 +50,15 @@ export async function* streamResponse(
65
50
  }
66
51
  }
67
52
  const ki = tokenStream(token, controller.signal, tokenStreamOptions);
68
- const stream = streamResponseInner(storage, params, syncParams, tracker, parseOptions, controller.signal);
53
+ const stream = streamResponseInner(
54
+ syncContext,
55
+ bucketStorage,
56
+ syncRules,
57
+ params,
58
+ syncParams,
59
+ tracker,
60
+ controller.signal
61
+ );
69
62
  // Merge the two streams, and abort as soon as one of the streams end.
70
63
  const merged = mergeAsyncIterables([stream, ki], controller.signal);
71
64
 
@@ -84,157 +77,168 @@ export async function* streamResponse(
84
77
  }
85
78
  }
86
79
 
80
+ export type BucketSyncState = {
81
+ description?: BucketDescription; // Undefined if the bucket has not yet been resolved by us.
82
+ start_op_id: string;
83
+ };
84
+
87
85
  async function* streamResponseInner(
88
- storage: storage.BucketStorageFactory,
86
+ syncContext: SyncContext,
87
+ bucketStorage: storage.SyncRulesBucketStorage,
88
+ syncRules: SqlSyncRules,
89
89
  params: util.StreamingSyncRequest,
90
90
  syncParams: RequestParameters,
91
91
  tracker: RequestTracker,
92
- parseOptions: storage.ParseSyncRulesOptions,
93
92
  signal: AbortSignal
94
93
  ): AsyncGenerator<util.StreamingSyncLine | string | null> {
95
- // Bucket state of bucket id -> op_id.
96
- // This starts with the state from the client. May contain buckets that the user do not have access to (anymore).
97
- let dataBuckets = new Map<string, string>();
98
-
99
- let lastChecksums: util.ChecksumMap | null = null;
100
- let lastWriteCheckpoint: bigint | null = null;
101
-
102
94
  const { raw_data, binary_data } = params;
103
95
 
104
- if (params.buckets) {
105
- for (let { name, after: start } of params.buckets) {
106
- dataBuckets.set(name, start);
107
- }
108
- }
109
-
110
96
  const checkpointUserId = util.checkpointUserId(syncParams.token_parameters.user_id as string, params.client_id);
111
- const stream = storage.watchWriteCheckpoint(checkpointUserId, signal);
112
- for await (const next of stream) {
113
- const { base, writeCheckpoint } = next;
114
- const checkpoint = base.checkpoint;
115
-
116
- const storage = await base.getBucketStorage();
117
- if (storage == null) {
118
- // Sync rules deleted in the meantime - try again with the next checkpoint.
119
- continue;
120
- }
121
- const syncRules = storage.getParsedSyncRules(parseOptions);
122
97
 
123
- const allBuckets = await syncRules.queryBucketIds({
124
- getParameterSets(lookups) {
125
- return storage.getParameterSets(checkpoint, lookups);
126
- },
127
- parameters: syncParams
128
- });
129
-
130
- if (allBuckets.length > 1000) {
131
- logger.error(`Too many buckets`, {
132
- checkpoint,
133
- user_id: syncParams.user_id,
134
- buckets: allBuckets.length
135
- });
136
- // TODO: Limit number of buckets even before we get to this point
137
- throw new Error(`Too many buckets: ${allBuckets.length}`);
138
- }
98
+ const checksumState = new BucketChecksumState({
99
+ syncContext,
100
+ bucketStorage,
101
+ syncRules,
102
+ syncParams,
103
+ initialBucketPositions: params.buckets
104
+ });
105
+ const stream = bucketStorage.watchWriteCheckpoint({
106
+ user_id: checkpointUserId,
107
+ signal
108
+ });
109
+ const newCheckpoints = stream[Symbol.asyncIterator]();
139
110
 
140
- let dataBucketsNew = new Map<string, string>();
141
- for (let bucket of allBuckets) {
142
- dataBucketsNew.set(bucket, dataBuckets.get(bucket) ?? '0');
143
- }
144
- dataBuckets = dataBucketsNew;
145
-
146
- const bucketList = [...dataBuckets.keys()];
147
- const checksumMap = await storage.getChecksums(checkpoint, bucketList);
148
- // Subset of buckets for which there may be new data in this batch.
149
- let bucketsToFetch: string[];
150
-
151
- if (lastChecksums) {
152
- const diff = util.checksumsDiff(lastChecksums, checksumMap);
153
-
154
- if (
155
- lastWriteCheckpoint == writeCheckpoint &&
156
- diff.removedBuckets.length == 0 &&
157
- diff.updatedBuckets.length == 0
158
- ) {
159
- // No changes - don't send anything to the client
111
+ try {
112
+ let nextCheckpointPromise:
113
+ | Promise<PromiseSettledResult<IteratorResult<storage.StorageCheckpointUpdate>>>
114
+ | undefined;
115
+
116
+ do {
117
+ if (!nextCheckpointPromise) {
118
+ // Wrap in a settledPromise, so that abort errors after the parent stopped iterating
119
+ // does not result in uncaught errors.
120
+ nextCheckpointPromise = settledPromise(newCheckpoints.next());
121
+ }
122
+ const next = await nextCheckpointPromise;
123
+ nextCheckpointPromise = undefined;
124
+ if (next.status == 'rejected') {
125
+ throw next.reason;
126
+ }
127
+ if (next.value.done) {
128
+ break;
129
+ }
130
+ const line = await checksumState.buildNextCheckpointLine(next.value.value);
131
+ if (line == null) {
132
+ // No update to sync
160
133
  continue;
161
134
  }
162
- bucketsToFetch = diff.updatedBuckets.map((c) => c.bucket);
163
-
164
- let message = `Updated checkpoint: ${checkpoint} | `;
165
- message += `write: ${writeCheckpoint} | `;
166
- message += `buckets: ${allBuckets.length} | `;
167
- message += `updated: ${limitedBuckets(diff.updatedBuckets, 20)} | `;
168
- message += `removed: ${limitedBuckets(diff.removedBuckets, 20)}`;
169
- logger.info(message, {
170
- checkpoint,
171
- user_id: syncParams.user_id,
172
- buckets: allBuckets.length,
173
- updated: diff.updatedBuckets.length,
174
- removed: diff.removedBuckets.length
175
- });
176
135
 
177
- const checksum_line: util.StreamingSyncCheckpointDiff = {
178
- checkpoint_diff: {
179
- last_op_id: checkpoint,
180
- write_checkpoint: writeCheckpoint ? String(writeCheckpoint) : undefined,
181
- removed_buckets: diff.removedBuckets,
182
- updated_buckets: diff.updatedBuckets
136
+ const { checkpointLine, bucketsToFetch } = line;
137
+
138
+ yield checkpointLine;
139
+ // Start syncing data for buckets up to the checkpoint. As soon as we have completed at least one priority and
140
+ // at least 1000 operations, we also start listening for new checkpoints concurrently. When a new checkpoint comes
141
+ // in while we're still busy syncing data for lower priorities, interrupt the current operation and start syncing
142
+ // the new checkpoint.
143
+ const abortCheckpointController = new AbortController();
144
+ let syncedOperations = 0;
145
+
146
+ const abortCheckpointSignal = AbortSignal.any([abortCheckpointController.signal, signal]);
147
+
148
+ const bucketsByPriority = [...Map.groupBy(bucketsToFetch, (bucket) => bucket.priority).entries()];
149
+ bucketsByPriority.sort((a, b) => a[0] - b[0]); // Sort from high to lower priorities
150
+ const lowestPriority = bucketsByPriority.at(-1)?.[0];
151
+
152
+ // Ensure that we have at least one priority batch: After sending the checkpoint line, clients expect to
153
+ // receive a sync complete message after the synchronization is done (which happens in the last
154
+ // bucketDataInBatches iteration). Without any batch, the line is missing and clients might not complete their
155
+ // sync properly.
156
+ const priorityBatches: [BucketPriority | null, BucketDescription[]][] = bucketsByPriority;
157
+ if (priorityBatches.length == 0) {
158
+ priorityBatches.push([null, []]);
159
+ }
160
+
161
+ function maybeRaceForNewCheckpoint() {
162
+ if (syncedOperations >= 1000 && nextCheckpointPromise === undefined) {
163
+ nextCheckpointPromise = (async () => {
164
+ const next = await settledPromise(newCheckpoints.next());
165
+ if (next.status == 'rejected') {
166
+ abortCheckpointController.abort();
167
+ } else if (!next.value.done) {
168
+ // Stop the running bucketDataInBatches() iterations, making the main flow reach the new checkpoint.
169
+ abortCheckpointController.abort();
170
+ }
171
+
172
+ return next;
173
+ })();
183
174
  }
184
- };
175
+ }
185
176
 
186
- yield checksum_line;
187
- } else {
188
- let message = `New checkpoint: ${checkpoint} | write: ${writeCheckpoint} | `;
189
- message += `buckets: ${allBuckets.length} ${limitedBuckets(allBuckets, 20)}`;
190
- logger.info(message, { checkpoint, user_id: syncParams.user_id, buckets: allBuckets.length });
191
- bucketsToFetch = allBuckets;
192
- const checksum_line: util.StreamingSyncCheckpoint = {
193
- checkpoint: {
194
- last_op_id: checkpoint,
195
- write_checkpoint: writeCheckpoint ? String(writeCheckpoint) : undefined,
196
- buckets: [...checksumMap.values()]
177
+ function markOperationsSent(operations: number) {
178
+ syncedOperations += operations;
179
+ tracker.addOperationsSynced(operations);
180
+ maybeRaceForNewCheckpoint();
181
+ }
182
+
183
+ // This incrementally updates dataBuckets with each individual bucket position.
184
+ // At the end of this, we can be sure that all buckets have data up to the checkpoint.
185
+ for (const [priority, buckets] of priorityBatches) {
186
+ const isLast = priority === lowestPriority;
187
+ if (abortCheckpointSignal.aborted) {
188
+ break;
197
189
  }
198
- };
199
- yield checksum_line;
200
- }
201
- lastChecksums = checksumMap;
202
- lastWriteCheckpoint = writeCheckpoint;
203
-
204
- // This incrementally updates dataBuckets with each individual bucket position.
205
- // At the end of this, we can be sure that all buckets have data up to the checkpoint.
206
- yield* bucketDataInBatches({
207
- storage,
208
- checkpoint,
209
- bucketsToFetch,
210
- dataBuckets,
211
- raw_data,
212
- binary_data,
213
- signal,
214
- tracker,
215
- user_id: syncParams.user_id
216
- });
217
-
218
- await new Promise((resolve) => setTimeout(resolve, 10));
190
+
191
+ yield* bucketDataInBatches({
192
+ syncContext: syncContext,
193
+ bucketStorage: bucketStorage,
194
+ checkpoint: next.value.value.base.checkpoint,
195
+ bucketsToFetch: buckets,
196
+ checksumState,
197
+ raw_data,
198
+ binary_data,
199
+ onRowsSent: markOperationsSent,
200
+ abort_connection: signal,
201
+ abort_batch: abortCheckpointSignal,
202
+ user_id: syncParams.user_id,
203
+ // Passing null here will emit a full sync complete message at the end. If we pass a priority, we'll emit a partial
204
+ // sync complete message instead.
205
+ forPriority: !isLast ? priority : null
206
+ });
207
+ }
208
+
209
+ if (!abortCheckpointSignal.aborted) {
210
+ await new Promise((resolve) => setTimeout(resolve, 10));
211
+ }
212
+ } while (!signal.aborted);
213
+ } finally {
214
+ await newCheckpoints.return?.();
219
215
  }
220
216
  }
221
217
 
222
218
  interface BucketDataRequest {
223
- storage: storage.SyncRulesBucketStorage;
219
+ syncContext: SyncContext;
220
+ bucketStorage: storage.SyncRulesBucketStorage;
224
221
  checkpoint: string;
225
- bucketsToFetch: string[];
226
- /** Bucket data position, modified by the request. */
227
- dataBuckets: Map<string, string>;
222
+ bucketsToFetch: BucketDescription[];
223
+ /** Contains current bucket state. Modified by the request as data is sent. */
224
+ checksumState: BucketChecksumState;
228
225
  raw_data: boolean | undefined;
229
226
  binary_data: boolean | undefined;
230
- tracker: RequestTracker;
231
- signal: AbortSignal;
227
+ /** Signals that the connection was aborted and that streaming should stop ASAP. */
228
+ abort_connection: AbortSignal;
229
+ /**
230
+ * Signals that higher-priority batches are available. The current batch can stop at a sensible point.
231
+ * This signal also fires when abort_connection fires.
232
+ */
233
+ abort_batch: AbortSignal;
232
234
  user_id?: string;
235
+ forPriority: BucketPriority | null;
236
+ onRowsSent: (amount: number) => void;
233
237
  }
234
238
 
235
239
  async function* bucketDataInBatches(request: BucketDataRequest) {
236
240
  let isDone = false;
237
- while (!request.signal.aborted && !isDone) {
241
+ while (!request.abort_batch.aborted && !isDone) {
238
242
  // The code below is functionally the same as this for-await loop below.
239
243
  // However, the for-await loop appears to have a memory leak, so we avoid it.
240
244
  // for await (const { done, data } of bucketDataBatch(storage, checkpoint, dataBuckets, raw_data, signal)) {
@@ -273,15 +277,31 @@ interface BucketDataBatchResult {
273
277
  * Extracted as a separate internal function just to avoid memory leaks.
274
278
  */
275
279
  async function* bucketDataBatch(request: BucketDataRequest): AsyncGenerator<BucketDataBatchResult, void> {
276
- const { storage, checkpoint, bucketsToFetch, dataBuckets, raw_data, binary_data, tracker, signal } = request;
280
+ const {
281
+ syncContext,
282
+ bucketStorage: storage,
283
+ checkpoint,
284
+ bucketsToFetch,
285
+ checksumState,
286
+ raw_data,
287
+ binary_data,
288
+ abort_connection,
289
+ abort_batch,
290
+ onRowsSent
291
+ } = request;
277
292
 
278
293
  const checkpointOp = BigInt(checkpoint);
279
294
  let checkpointInvalidated = false;
280
295
 
281
- if (syncSemaphore.isLocked()) {
296
+ if (syncContext.syncSemaphore.isLocked()) {
282
297
  logger.info('Sync concurrency limit reached, waiting for lock', { user_id: request.user_id });
283
298
  }
284
- const [value, release] = await syncSemaphore.acquire();
299
+ const acquired = await acquireSemaphoreAbortable(syncContext.syncSemaphore, AbortSignal.any([abort_batch]));
300
+ if (acquired === 'aborted') {
301
+ return;
302
+ }
303
+
304
+ const [value, release] = acquired;
285
305
  try {
286
306
  if (value <= 3) {
287
307
  // This can be noisy, so we only log when we get close to the
@@ -293,13 +313,14 @@ async function* bucketDataBatch(request: BucketDataRequest): AsyncGenerator<Buck
293
313
  }
294
314
  // Optimization: Only fetch buckets for which the checksums have changed since the last checkpoint
295
315
  // For the first batch, this will be all buckets.
296
- const filteredBuckets = new Map(bucketsToFetch.map((bucket) => [bucket, dataBuckets.get(bucket)!]));
297
- const data = storage.getBucketDataBatch(checkpoint, filteredBuckets);
316
+ const filteredBuckets = checksumState.getFilteredBucketPositions(bucketsToFetch);
317
+ const dataBatches = storage.getBucketDataBatch(checkpoint, filteredBuckets);
298
318
 
299
319
  let has_more = false;
300
320
 
301
- for await (let { batch: r, targetOp } of data) {
302
- if (signal.aborted) {
321
+ for await (let { batch: r, targetOp } of dataBatches) {
322
+ // Abort in current batch if the connection is closed
323
+ if (abort_connection.aborted) {
303
324
  return;
304
325
  }
305
326
  if (r.has_more) {
@@ -339,9 +360,15 @@ async function* bucketDataBatch(request: BucketDataRequest): AsyncGenerator<Buck
339
360
  // iterator memory in case if large data sent.
340
361
  yield { data: null, done: false };
341
362
  }
342
- tracker.addOperationsSynced(r.data.length);
363
+ onRowsSent(r.data.length);
364
+
365
+ checksumState.updateBucketPosition({ bucket: r.bucket, nextAfter: r.next_after, hasMore: r.has_more });
343
366
 
344
- dataBuckets.set(r.bucket, r.next_after);
367
+ // Check if syncing bucket data is supposed to stop before fetching more data
368
+ // from storage.
369
+ if (abort_batch.aborted) {
370
+ return;
371
+ }
345
372
  }
346
373
 
347
374
  if (!has_more) {
@@ -351,12 +378,22 @@ async function* bucketDataBatch(request: BucketDataRequest): AsyncGenerator<Buck
351
378
  // More data should be available immediately for a new checkpoint.
352
379
  yield { data: null, done: true };
353
380
  } else {
354
- const line: util.StreamingSyncCheckpointComplete = {
355
- checkpoint_complete: {
356
- last_op_id: checkpoint
357
- }
358
- };
359
- yield { data: line, done: true };
381
+ if (request.forPriority != null) {
382
+ const line: util.StreamingSyncCheckpointPartiallyComplete = {
383
+ partial_checkpoint_complete: {
384
+ last_op_id: checkpoint,
385
+ priority: request.forPriority
386
+ }
387
+ };
388
+ yield { data: line, done: true };
389
+ } else {
390
+ const line: util.StreamingSyncCheckpointComplete = {
391
+ checkpoint_complete: {
392
+ last_op_id: checkpoint
393
+ }
394
+ };
395
+ yield { data: line, done: true };
396
+ }
360
397
  }
361
398
  }
362
399
  } finally {
@@ -383,18 +420,3 @@ function transformLegacyResponse(bucketData: util.SyncBucketData): any {
383
420
  })
384
421
  };
385
422
  }
386
-
387
- function limitedBuckets(buckets: string[] | util.BucketChecksum[], limit: number) {
388
- buckets = buckets.map((b) => {
389
- if (typeof b != 'string') {
390
- return b.bucket;
391
- } else {
392
- return b;
393
- }
394
- });
395
- if (buckets.length <= limit) {
396
- return JSON.stringify(buckets);
397
- }
398
- const limited = buckets.slice(0, limit);
399
- return `${JSON.stringify(limited)}...`;
400
- }
package/src/sync/util.ts CHANGED
@@ -2,6 +2,7 @@ import * as timers from 'timers/promises';
2
2
 
3
3
  import * as util from '../util/util-index.js';
4
4
  import { RequestTracker } from './RequestTracker.js';
5
+ import { SemaphoreInterface } from 'async-mutex';
5
6
 
6
7
  export type TokenStreamOptions = {
7
8
  /**
@@ -99,3 +100,56 @@ export async function* transformToBytesTracked(
99
100
  yield encoded;
100
101
  }
101
102
  }
103
+
104
+ export function acquireSemaphoreAbortable(
105
+ semaphone: SemaphoreInterface,
106
+ abort: AbortSignal
107
+ ): Promise<[number, SemaphoreInterface.Releaser] | 'aborted'> {
108
+ return new Promise((resolve, reject) => {
109
+ let aborted = false;
110
+ let hasSemaphore = false;
111
+
112
+ const listener = () => {
113
+ if (!hasSemaphore) {
114
+ aborted = true;
115
+ abort.removeEventListener('abort', listener);
116
+ resolve('aborted');
117
+ }
118
+ };
119
+ abort.addEventListener('abort', listener);
120
+
121
+ semaphone.acquire().then((acquired) => {
122
+ hasSemaphore = true;
123
+ if (aborted) {
124
+ // Release semaphore, already aborted
125
+ acquired[1]();
126
+ } else {
127
+ abort.removeEventListener('abort', listener);
128
+ resolve(acquired);
129
+ }
130
+ }, reject);
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Wrap a promise in the style of Promise.allSettled.
136
+ *
137
+ * This is specifically useful if rejections should not be treated as uncaught rejections
138
+ * if it is not specifically handled.
139
+ */
140
+ export function settledPromise<T>(promise: Promise<T>): Promise<PromiseSettledResult<T>> {
141
+ return promise.then(
142
+ (result) => {
143
+ return {
144
+ status: 'fulfilled',
145
+ value: result
146
+ };
147
+ },
148
+ (error) => {
149
+ return {
150
+ status: 'rejected',
151
+ reason: error
152
+ };
153
+ }
154
+ );
155
+ }
@@ -7,6 +7,7 @@ import * as replication from '../replication/replication-index.js';
7
7
  import * as routes from '../routes/routes-index.js';
8
8
  import * as storage from '../storage/storage-index.js';
9
9
  import * as utils from '../util/util-index.js';
10
+ import { SyncContext } from '../sync/SyncContext.js';
10
11
 
11
12
  export interface ServiceContext {
12
13
  configuration: utils.ResolvedPowerSyncConfig;
@@ -16,6 +17,7 @@ export interface ServiceContext {
16
17
  routerEngine: routes.RouterEngine | null;
17
18
  storageEngine: storage.StorageEngine;
18
19
  migrations: PowerSyncMigrationManager;
20
+ syncContext: SyncContext;
19
21
  }
20
22
 
21
23
  /**
@@ -26,6 +28,7 @@ export interface ServiceContext {
26
28
  export class ServiceContextContainer implements ServiceContext {
27
29
  lifeCycleEngine: LifeCycledSystem;
28
30
  storageEngine: storage.StorageEngine;
31
+ syncContext: SyncContext;
29
32
 
30
33
  constructor(public configuration: utils.ResolvedPowerSyncConfig) {
31
34
  this.lifeCycleEngine = new LifeCycledSystem();
@@ -34,6 +37,12 @@ export class ServiceContextContainer implements ServiceContext {
34
37
  configuration
35
38
  });
36
39
 
40
+ this.syncContext = new SyncContext({
41
+ maxDataFetchConcurrency: configuration.api_parameters.max_data_fetch_concurrency,
42
+ maxBuckets: configuration.api_parameters.max_buckets_per_connection,
43
+ maxParameterQueryResults: configuration.api_parameters.max_parameter_query_results
44
+ });
45
+
37
46
  const migrationManager = new MigrationManager();
38
47
  container.register(framework.ContainerImplementation.MIGRATION_MANAGER, migrationManager);
39
48
 
@@ -0,0 +1,41 @@
1
+ import { ErrorCode, ServiceError } from '@powersync/lib-services-framework';
2
+ import { RouteAPI } from '../api/RouteAPI.js';
3
+ import { BucketStorageFactory } from '../storage/storage-index.js';
4
+
5
+ export interface CreateWriteCheckpointOptions {
6
+ userId: string | undefined;
7
+ clientId: string | undefined;
8
+ api: RouteAPI;
9
+ storage: BucketStorageFactory;
10
+ }
11
+ export async function createWriteCheckpoint(options: CreateWriteCheckpointOptions) {
12
+ const full_user_id = checkpointUserId(options.userId, options.clientId);
13
+
14
+ const syncBucketStorage = await options.storage.getActiveStorage();
15
+ if (!syncBucketStorage) {
16
+ throw new ServiceError(ErrorCode.PSYNC_S2302, `Cannot create Write Checkpoint since no sync rules are active.`);
17
+ }
18
+
19
+ const { writeCheckpoint, currentCheckpoint } = await options.api.createReplicationHead(async (currentCheckpoint) => {
20
+ const writeCheckpoint = await syncBucketStorage.createManagedWriteCheckpoint({
21
+ user_id: full_user_id,
22
+ heads: { '1': currentCheckpoint }
23
+ });
24
+ return { writeCheckpoint, currentCheckpoint };
25
+ });
26
+
27
+ return {
28
+ writeCheckpoint: String(writeCheckpoint),
29
+ replicationHead: currentCheckpoint
30
+ };
31
+ }
32
+
33
+ export function checkpointUserId(user_id: string | undefined, client_id: string | undefined) {
34
+ if (user_id == null) {
35
+ throw new Error('user_id is required');
36
+ }
37
+ if (client_id == null) {
38
+ return user_id;
39
+ }
40
+ return `${user_id}/${client_id}`;
41
+ }