@powersync/service-core 1.23.2 → 1.24.0
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/CHANGELOG.md +39 -0
- package/dist/api/RouteAPI.d.ts +17 -3
- package/dist/entry/commands/compact-action.js +4 -0
- package/dist/entry/commands/compact-action.js.map +1 -1
- package/dist/replication/AbstractReplicator.d.ts +9 -4
- package/dist/replication/AbstractReplicator.js +35 -11
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/routes/configure-fastify.d.ts +31 -0
- package/dist/routes/endpoints/checkpointing.d.ts +62 -0
- package/dist/routes/endpoints/checkpointing.js +63 -3
- package/dist/routes/endpoints/checkpointing.js.map +1 -1
- package/dist/routes/endpoints/socket-route.js +2 -1
- package/dist/routes/endpoints/socket-route.js.map +1 -1
- package/dist/storage/BucketStorageBatch.d.ts +4 -1
- package/dist/storage/BucketStorageBatch.js.map +1 -1
- package/dist/storage/BucketStorageFactory.d.ts +2 -2
- package/dist/storage/CheckpointChecksumInvalidatedError.d.ts +12 -0
- package/dist/storage/CheckpointChecksumInvalidatedError.js +17 -0
- package/dist/storage/CheckpointChecksumInvalidatedError.js.map +1 -0
- package/dist/storage/SyncRulesBucketStorage.d.ts +26 -1
- package/dist/storage/SyncRulesBucketStorage.js +3 -0
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/storage/WriteCheckpointAPI.d.ts +60 -3
- package/dist/storage/WriteCheckpointAPI.js +33 -0
- package/dist/storage/WriteCheckpointAPI.js.map +1 -1
- package/dist/storage/implementation/BucketDefinitionMapping.d.ts +4 -4
- package/dist/storage/implementation/BucketDefinitionMapping.js.map +1 -1
- package/dist/storage/storage-index.d.ts +1 -0
- package/dist/storage/storage-index.js +1 -0
- package/dist/storage/storage-index.js.map +1 -1
- package/dist/sync/BucketChecksumState.d.ts +7 -0
- package/dist/sync/BucketChecksumState.js +33 -13
- package/dist/sync/BucketChecksumState.js.map +1 -1
- package/dist/sync/sync.js +34 -6
- package/dist/sync/sync.js.map +1 -1
- package/dist/sync/util.d.ts +5 -0
- package/dist/sync/util.js +9 -0
- package/dist/sync/util.js.map +1 -1
- package/dist/util/checkpointing.d.ts +1 -0
- package/dist/util/checkpointing.js +1 -1
- package/dist/util/checkpointing.js.map +1 -1
- package/dist/util/config/compound-config-collector.js +9 -1
- package/dist/util/config/compound-config-collector.js.map +1 -1
- package/dist/util/config/defaults.d.ts +1 -0
- package/dist/util/config/defaults.js +1 -0
- package/dist/util/config/defaults.js.map +1 -1
- package/dist/util/config/types.d.ts +1 -0
- package/dist/util/write-checkpoint-batcher.d.ts +1 -1
- package/dist/util/write-checkpoint-batcher.js +18 -8
- package/dist/util/write-checkpoint-batcher.js.map +1 -1
- package/package.json +5 -5
- package/src/api/RouteAPI.ts +18 -3
- package/src/entry/commands/compact-action.ts +6 -0
- package/src/replication/AbstractReplicator.ts +42 -12
- package/src/routes/endpoints/checkpointing.ts +77 -3
- package/src/routes/endpoints/socket-route.ts +2 -1
- package/src/storage/BucketStorageBatch.ts +4 -1
- package/src/storage/BucketStorageFactory.ts +2 -2
- package/src/storage/CheckpointChecksumInvalidatedError.ts +17 -0
- package/src/storage/SyncRulesBucketStorage.ts +32 -1
- package/src/storage/WriteCheckpointAPI.ts +108 -3
- package/src/storage/implementation/BucketDefinitionMapping.ts +4 -4
- package/src/storage/storage-index.ts +1 -0
- package/src/sync/BucketChecksumState.ts +38 -13
- package/src/sync/sync.ts +33 -7
- package/src/sync/util.ts +12 -0
- package/src/util/checkpointing.ts +2 -1
- package/src/util/config/compound-config-collector.ts +12 -0
- package/src/util/config/defaults.ts +1 -0
- package/src/util/config/types.ts +1 -0
- package/src/util/write-checkpoint-batcher.ts +30 -10
- package/test/src/AbstractReplicator.test.ts +147 -0
- package/test/src/config.test.ts +60 -0
- package/test/src/routes/checkpointing.test.ts +54 -0
- package/test/src/sync/BucketChecksumState.test.ts +57 -11
- package/test/src/sync/util.test.ts +13 -1
- package/test/src/util/checkpointing.test.ts +99 -20
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,10 +1,49 @@
|
|
|
1
|
-
import { logger, router, schema } from '@powersync/lib-services-framework';
|
|
1
|
+
import { codecs, logger, router, schema } from '@powersync/lib-services-framework';
|
|
2
2
|
import * as t from 'ts-codec';
|
|
3
3
|
|
|
4
4
|
import * as util from '../../util/util-index.js';
|
|
5
5
|
import { authUser } from '../auth.js';
|
|
6
6
|
import { routeDefinition } from '../router.js';
|
|
7
7
|
|
|
8
|
+
const CHECKPOINT_REQUEST_ID_MAX = 9_223_372_036_854_775_807n;
|
|
9
|
+
|
|
10
|
+
function isValidCheckpointRequestId(value: bigint) {
|
|
11
|
+
return value > 0n && value <= CHECKPOINT_REQUEST_ID_MAX;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const CheckpointRequestPayload = t.object({
|
|
15
|
+
client_id: t.string,
|
|
16
|
+
// Positive int64, matching the managed write checkpoint id. Clients should
|
|
17
|
+
// send values larger than Number.MAX_SAFE_INTEGER as strings, since JSON
|
|
18
|
+
// numbers are only validated up to the safe-integer range.
|
|
19
|
+
checkpoint_request_id: codecs.bigint
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const CheckpointRequestPayloadShapeValidator = schema.createTsCodecValidator(CheckpointRequestPayload, {
|
|
23
|
+
allowAdditional: true
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const CheckpointRequestPayloadValidator = {
|
|
27
|
+
validate(params: t.Encoded<typeof CheckpointRequestPayload>) {
|
|
28
|
+
const shapeValidation = CheckpointRequestPayloadShapeValidator.validate(params);
|
|
29
|
+
if (!shapeValidation.valid) {
|
|
30
|
+
return shapeValidation;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const decodedParams = CheckpointRequestPayload.decode(params);
|
|
34
|
+
if (!isValidCheckpointRequestId(decodedParams.checkpoint_request_id)) {
|
|
35
|
+
return {
|
|
36
|
+
valid: false as const,
|
|
37
|
+
errors: [`Expected checkpoint_request_id between 1 and ${CHECKPOINT_REQUEST_ID_MAX}`]
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
valid: true as const
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
8
47
|
const WriteCheckpointRequest = t.object({
|
|
9
48
|
client_id: t.string.optional()
|
|
10
49
|
});
|
|
@@ -24,7 +63,7 @@ export const writeCheckpoint = routeDefinition({
|
|
|
24
63
|
// Since we don't use LSNs anymore, the only way to get that is to wait.
|
|
25
64
|
const start = Date.now();
|
|
26
65
|
|
|
27
|
-
const head = await apiHandler.createReplicationHead(async (head) => head);
|
|
66
|
+
const head = await apiHandler.createReplicationHead(async (head) => ({ response: head, shouldAdvance: true }));
|
|
28
67
|
|
|
29
68
|
const timeout = 50_000;
|
|
30
69
|
|
|
@@ -70,4 +109,39 @@ export const writeCheckpoint2 = routeDefinition({
|
|
|
70
109
|
}
|
|
71
110
|
});
|
|
72
111
|
|
|
73
|
-
export const
|
|
112
|
+
export const checkpointRequest = routeDefinition({
|
|
113
|
+
path: '/sync/checkpoint-request',
|
|
114
|
+
method: router.HTTPMethod.POST,
|
|
115
|
+
authorize: authUser,
|
|
116
|
+
validator: CheckpointRequestPayloadValidator,
|
|
117
|
+
handler: async (request) => {
|
|
118
|
+
const { token_payload, service_context } = request.context;
|
|
119
|
+
const { params } = request;
|
|
120
|
+
|
|
121
|
+
const decodedParams = CheckpointRequestPayload.decode(params);
|
|
122
|
+
|
|
123
|
+
// Storage only applies supplied request ids that advance the stored managed checkpoint.
|
|
124
|
+
// Stale or duplicate ids return the stored checkpoint; storage decides whether
|
|
125
|
+
// the matched checkpoint still needs a source marker.
|
|
126
|
+
const { replicationHead, writeCheckpoint } = await util.createWriteCheckpoint({
|
|
127
|
+
userId: token_payload!.userIdString,
|
|
128
|
+
clientId: decodedParams.client_id,
|
|
129
|
+
batcher: service_context.writeCheckpointBatcher,
|
|
130
|
+
checkpointRequestId: decodedParams.checkpoint_request_id
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
logger.info(
|
|
134
|
+
`Requested checkpoint for ${token_payload!.userIdString}/${decodedParams.client_id}: ${writeCheckpoint} | ${replicationHead}`
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Return the checkpoint request id storage is actually at after this request.
|
|
138
|
+
// When an earlier request has already advanced the stored value beyond the
|
|
139
|
+
// supplied checkpoint_request_id, this returns that larger previous value so
|
|
140
|
+
// the client can treat the response as a stale-request acknowledgement.
|
|
141
|
+
return {
|
|
142
|
+
checkpoint_request_id: String(writeCheckpoint)
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
export const CHECKPOINT_ROUTES = [checkpointRequest, writeCheckpoint, writeCheckpoint2];
|
|
@@ -163,8 +163,9 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
|
|
|
163
163
|
} catch (ex) {
|
|
164
164
|
// Convert to our standard form before responding.
|
|
165
165
|
// This ensures the error can be serialized.
|
|
166
|
+
// However, use the original error for the logs, so that we have the stack trace.
|
|
166
167
|
const error = new errors.InternalServerError(ex);
|
|
167
|
-
logger.error('Sync stream error',
|
|
168
|
+
logger.error('Sync stream error', ex);
|
|
168
169
|
closeReason ??= 'stream error';
|
|
169
170
|
responder.onError(error);
|
|
170
171
|
} finally {
|
|
@@ -173,7 +173,10 @@ export interface BucketStorageBatch extends ObserverClient<BucketBatchStorageLis
|
|
|
173
173
|
resolveTables(options: ResolveTablesOptions): Promise<ResolveTablesResult>;
|
|
174
174
|
|
|
175
175
|
/**
|
|
176
|
-
* Queue a custom
|
|
176
|
+
* Queue a custom checkpoint request to be persisted after operations are
|
|
177
|
+
* flushed. Set `checkpoint_requested_at` when the custom checkpoint came from
|
|
178
|
+
* a client checkpoint request and should be cleaned up by request-retention
|
|
179
|
+
* compaction; omit it for persistent source-owned checkpoints.
|
|
177
180
|
*/
|
|
178
181
|
addCustomWriteCheckpoint(checkpoint: BatchedCustomWriteCheckpointOptions): void;
|
|
179
182
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { BaseObserver, logger } from '@powersync/lib-services-framework';
|
|
2
2
|
import {
|
|
3
3
|
PrecompiledSyncConfig,
|
|
4
|
+
SerializedSyncPlan as RawSerializedSyncPlan,
|
|
4
5
|
SerializedCompatibilityContext,
|
|
5
|
-
SerializedSyncPlanV1,
|
|
6
6
|
serializeSyncPlan,
|
|
7
7
|
SqlSyncRules,
|
|
8
8
|
SyncConfigWithErrors
|
|
@@ -171,7 +171,7 @@ export interface SerializedSyncPlan {
|
|
|
171
171
|
/**
|
|
172
172
|
* The serialized plan, from {@link serializeSyncPlan}.
|
|
173
173
|
*/
|
|
174
|
-
plan:
|
|
174
|
+
plan: RawSerializedSyncPlan;
|
|
175
175
|
compatibility: SerializedCompatibilityContext;
|
|
176
176
|
/**
|
|
177
177
|
* Event descriptors are not currently represented in the sync plan because they don't use the sync streams compiler
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { InternalOpId } from '../util/util-index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A checkpoint cannot be served because compaction rewrote a bucket-data
|
|
5
|
+
* document across its end boundary.
|
|
6
|
+
*
|
|
7
|
+
* The sync loop must skip this checkpoint before it sends its checkpoint line.
|
|
8
|
+
*/
|
|
9
|
+
export class CheckpointChecksumInvalidatedError extends Error {
|
|
10
|
+
constructor(
|
|
11
|
+
public readonly checkpoint: InternalOpId,
|
|
12
|
+
public readonly bucket: string
|
|
13
|
+
) {
|
|
14
|
+
super(`Checkpoint ${checkpoint} was invalidated by compaction in bucket ${bucket}`);
|
|
15
|
+
this.name = 'CheckpointChecksumInvalidatedError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -133,6 +133,13 @@ export interface SyncRulesBucketStorage
|
|
|
133
133
|
* 1. Separate buckets.
|
|
134
134
|
* 2. Limit the size of each individual chunk according to options.batchSizeLimitBytes.
|
|
135
135
|
*
|
|
136
|
+
* The batch may not contain all data for the checkpoint, if the checkpoint is large. The caller must
|
|
137
|
+
* continue querying if either:
|
|
138
|
+
* 1. The last chunk for any bucket has has_more = true.
|
|
139
|
+
* 2. A SyncBucketDataBatchEnd is returned with hasMore = true.
|
|
140
|
+
*
|
|
141
|
+
* The first check can be skipped if a SyncBucketDataBatchEnd is returned with hasMore = false.
|
|
142
|
+
*
|
|
136
143
|
* @param checkpoint the checkpoint
|
|
137
144
|
* @param dataBuckets current bucket states
|
|
138
145
|
* @param options batch size options
|
|
@@ -141,7 +148,7 @@ export interface SyncRulesBucketStorage
|
|
|
141
148
|
checkpoint: ReplicationCheckpoint,
|
|
142
149
|
dataBuckets: BucketDataRequest[],
|
|
143
150
|
options?: BucketDataBatchOptions
|
|
144
|
-
): AsyncIterable<SyncBucketDataChunk>;
|
|
151
|
+
): AsyncIterable<SyncBucketDataChunk | SyncBucketDataBatchEnd>;
|
|
145
152
|
|
|
146
153
|
/**
|
|
147
154
|
* Compute checksums for a given list of buckets.
|
|
@@ -308,6 +315,13 @@ export interface CompactOptions {
|
|
|
308
315
|
|
|
309
316
|
compactParameterData?: boolean;
|
|
310
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Delete client-requested write checkpoints created before this time.
|
|
320
|
+
*
|
|
321
|
+
* Generated write checkpoints are not affected.
|
|
322
|
+
*/
|
|
323
|
+
deleteCheckpointRequestsBefore?: Date;
|
|
324
|
+
|
|
311
325
|
/** Minimum of 2 */
|
|
312
326
|
clearBatchLimit?: number;
|
|
313
327
|
|
|
@@ -396,6 +410,9 @@ export interface TerminateOptions extends ClearStorageOptions {
|
|
|
396
410
|
export interface BucketDataBatchOptions {
|
|
397
411
|
requestHint?: BucketRequestHint;
|
|
398
412
|
|
|
413
|
+
/** Abort any in-progress work for this batch, including object-storage downloads. */
|
|
414
|
+
signal?: AbortSignal;
|
|
415
|
+
|
|
399
416
|
/** Limit number of documents returned. Defaults to 1000. */
|
|
400
417
|
limit?: number;
|
|
401
418
|
|
|
@@ -416,6 +433,20 @@ export interface SyncBucketDataChunk {
|
|
|
416
433
|
targetOp: util.InternalOpId | null;
|
|
417
434
|
}
|
|
418
435
|
|
|
436
|
+
export interface SyncBucketDataBatchEnd {
|
|
437
|
+
/**
|
|
438
|
+
* True if there may be more data for this checkpoint, and the caller should continue querying.
|
|
439
|
+
*
|
|
440
|
+
* This is different from `SyncBucketDataChunk.has_more`, which is per-bucket. This is a global signal for the
|
|
441
|
+
* entire request, and may be true even if there is no returned chunk with has_more: true.
|
|
442
|
+
*/
|
|
443
|
+
hasMore: boolean;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function isBatchEnd(chunk: SyncBucketDataChunk | SyncBucketDataBatchEnd): chunk is SyncBucketDataBatchEnd {
|
|
447
|
+
return (chunk as SyncBucketDataBatchEnd).hasMore !== undefined;
|
|
448
|
+
}
|
|
449
|
+
|
|
419
450
|
export interface ReplicationCheckpoint {
|
|
420
451
|
readonly checkpoint: util.InternalOpId;
|
|
421
452
|
readonly lsn: string | null;
|
|
@@ -19,6 +19,18 @@ export interface BaseWriteCheckpointIdentifier {
|
|
|
19
19
|
user_id: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export interface ClientRequestedCheckpointOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Supplied for client-generated checkpoint requests.
|
|
25
|
+
*
|
|
26
|
+
* If omitted, storage creates or stores a regular write checkpoint.
|
|
27
|
+
* If supplied for managed write checkpoints, storage only uses it when it is
|
|
28
|
+
* greater than the stored id for the user_id. Same or lower supplied ids are
|
|
29
|
+
* no-ops, and storage returns the current stored id.
|
|
30
|
+
*/
|
|
31
|
+
checkpoint_request_id?: bigint;
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
export interface CustomWriteCheckpointFilters extends BaseWriteCheckpointIdentifier {
|
|
23
35
|
/**
|
|
24
36
|
* Replication stream which was active when this checkpoint was created.
|
|
@@ -28,9 +40,17 @@ export interface CustomWriteCheckpointFilters extends BaseWriteCheckpointIdentif
|
|
|
28
40
|
|
|
29
41
|
export interface BatchedCustomWriteCheckpointOptions extends BaseWriteCheckpointIdentifier {
|
|
30
42
|
/**
|
|
31
|
-
* A supplied incrementing
|
|
43
|
+
* A supplied incrementing checkpoint request id. This is still named
|
|
44
|
+
* "write checkpoint" in storage APIs for backwards compatibility.
|
|
32
45
|
*/
|
|
33
46
|
checkpoint: bigint;
|
|
47
|
+
/**
|
|
48
|
+
* Required when this custom checkpoint was created from a client checkpoint
|
|
49
|
+
* request and should be eligible for checkpoint request retention cleanup.
|
|
50
|
+
* Omit or set to null for persistent custom checkpoints owned by the source
|
|
51
|
+
* or integration.
|
|
52
|
+
*/
|
|
53
|
+
checkpoint_requested_at?: Date | null;
|
|
34
54
|
}
|
|
35
55
|
|
|
36
56
|
export interface CustomWriteCheckpointOptions extends BatchedCustomWriteCheckpointOptions {
|
|
@@ -50,7 +70,90 @@ export interface ManagedWriteCheckpointFilters extends BaseWriteCheckpointIdenti
|
|
|
50
70
|
heads: Record<string, string>;
|
|
51
71
|
}
|
|
52
72
|
|
|
53
|
-
export type ManagedWriteCheckpointOptions = ManagedWriteCheckpointFilters;
|
|
73
|
+
export type ManagedWriteCheckpointOptions = ManagedWriteCheckpointFilters & ClientRequestedCheckpointOptions;
|
|
74
|
+
|
|
75
|
+
export interface CreateManagedWriteCheckpointsResult {
|
|
76
|
+
/**
|
|
77
|
+
* Current managed checkpoint id for each full user_id after applying the batch.
|
|
78
|
+
*/
|
|
79
|
+
writeCheckpoints: Map<string, bigint>;
|
|
80
|
+
/**
|
|
81
|
+
* True when the source marker must be forced so replication observes the
|
|
82
|
+
* stored head. Callers use this to advance the source marker once for the
|
|
83
|
+
* whole batch.
|
|
84
|
+
*
|
|
85
|
+
* This must be true whenever a checkpoint in the batch has not yet been
|
|
86
|
+
* processed by replication - both freshly created/advanced checkpoints and
|
|
87
|
+
* existing checkpoints matched by a stale or duplicate request that are still
|
|
88
|
+
* pending. Already-processed checkpoints do not need a new marker.
|
|
89
|
+
*
|
|
90
|
+
* The case this guards against is a lost source marker on a stale retry. The
|
|
91
|
+
* source marker is forced after the checkpoint is persisted, so the two are
|
|
92
|
+
* not atomic (storage and source are usually different databases). Without
|
|
93
|
+
* this flag, the following sequence strands the checkpoint:
|
|
94
|
+
*
|
|
95
|
+
* 1. A client-supplied checkpoint id is persisted (write checkpoint stored,
|
|
96
|
+
* processed_at_lsn null).
|
|
97
|
+
* 2. Forcing the source marker fails, so the request errors and the client
|
|
98
|
+
* retries with the same id.
|
|
99
|
+
* 3. The retry is a no-op for the stored id (monotonic, not greater), so if
|
|
100
|
+
* we only advanced on "a row changed" we would skip the marker.
|
|
101
|
+
* 4. On an idle source nothing else moves replication past the stored head,
|
|
102
|
+
* so the checkpoint never gets acknowledged and the client waits forever.
|
|
103
|
+
*
|
|
104
|
+
* Reporting true while the checkpoint is still pending makes the retry re-force
|
|
105
|
+
* the marker, which resolves the wait.
|
|
106
|
+
*
|
|
107
|
+
* Backends that do not track a per-row processed indicator may conservatively
|
|
108
|
+
* report true whenever any checkpoint was matched.
|
|
109
|
+
*/
|
|
110
|
+
shouldAdvance: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function uniqueManagedWriteCheckpoints(
|
|
114
|
+
checkpoints: ManagedWriteCheckpointOptions[]
|
|
115
|
+
): ManagedWriteCheckpointOptions[] {
|
|
116
|
+
const byUser = new Map<string, ManagedWriteCheckpointOptions>();
|
|
117
|
+
|
|
118
|
+
for (const checkpoint of checkpoints) {
|
|
119
|
+
const existing = byUser.get(checkpoint.user_id);
|
|
120
|
+
// A batch can contain entries for many users, and different users may use
|
|
121
|
+
// different request types (generated vs client-supplied). For a single full
|
|
122
|
+
// user_id, though, all entries should be the same type - a batch can still
|
|
123
|
+
// hold multiple entries for that user (e.g. coalesced requests), so we
|
|
124
|
+
// collapse them to one. For client-supplied ids we keep the greatest
|
|
125
|
+
// requested value, so lower stale ids cannot hide the request that should
|
|
126
|
+
// advance storage. For generated ids the entries are equivalent, so any one
|
|
127
|
+
// is kept.
|
|
128
|
+
//
|
|
129
|
+
// Mixing generated and supplied entries for the same user_id is not expected;
|
|
130
|
+
// shouldReplaceManagedWriteCheckpoint still resolves it deterministically
|
|
131
|
+
// (supplied wins) if that invariant is ever violated.
|
|
132
|
+
if (existing == null || shouldReplaceManagedWriteCheckpoint(existing, checkpoint)) {
|
|
133
|
+
byUser.set(checkpoint.user_id, checkpoint);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return [...byUser.values()];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function shouldReplaceManagedWriteCheckpoint(
|
|
141
|
+
existing: ManagedWriteCheckpointOptions,
|
|
142
|
+
candidate: ManagedWriteCheckpointOptions
|
|
143
|
+
) {
|
|
144
|
+
const existingRequestId = existing.checkpoint_request_id;
|
|
145
|
+
const candidateRequestId = candidate.checkpoint_request_id;
|
|
146
|
+
|
|
147
|
+
if (candidateRequestId == null) {
|
|
148
|
+
return existingRequestId == null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (existingRequestId == null) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return candidateRequestId > existingRequestId;
|
|
156
|
+
}
|
|
54
157
|
|
|
55
158
|
export type SyncStorageLastWriteCheckpointFilters = BaseWriteCheckpointIdentifier | ManagedWriteCheckpointFilters;
|
|
56
159
|
export type LastWriteCheckpointFilters = CustomWriteCheckpointFilters | ManagedWriteCheckpointFilters;
|
|
@@ -58,7 +161,9 @@ export type LastWriteCheckpointFilters = CustomWriteCheckpointFilters | ManagedW
|
|
|
58
161
|
export interface BaseWriteCheckpointAPI {
|
|
59
162
|
readonly writeCheckpointMode: WriteCheckpointMode;
|
|
60
163
|
setWriteCheckpointMode(mode: WriteCheckpointMode): void;
|
|
61
|
-
createManagedWriteCheckpoints(
|
|
164
|
+
createManagedWriteCheckpoints(
|
|
165
|
+
checkpoints: ManagedWriteCheckpointOptions[]
|
|
166
|
+
): Promise<CreateManagedWriteCheckpointsResult>;
|
|
62
167
|
}
|
|
63
168
|
|
|
64
169
|
/**
|
|
@@ -10,13 +10,13 @@ import {
|
|
|
10
10
|
SerializedParameterIndexLookupCreator,
|
|
11
11
|
serializedStreamBucketDataSourceEquality,
|
|
12
12
|
serializedStreamParameterIndexLookupCreatorEquality,
|
|
13
|
-
|
|
13
|
+
SerializedSyncPlan,
|
|
14
14
|
SourceTableRef,
|
|
15
15
|
SyncConfigWithErrors
|
|
16
16
|
} from '@powersync/service-sync-rules';
|
|
17
17
|
|
|
18
18
|
export interface SerializedSyncConfigWithMapping {
|
|
19
|
-
plan:
|
|
19
|
+
plan: SerializedSyncPlan;
|
|
20
20
|
mapping: SingleSyncConfigBucketDefinitionMapping;
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -131,7 +131,7 @@ export class SingleSyncConfigBucketDefinitionMapping implements BucketDefinition
|
|
|
131
131
|
*/
|
|
132
132
|
static constructIncrementalMappingFromSerializedPlans(
|
|
133
133
|
compatibleConfigs: SerializedSyncConfigWithMapping[],
|
|
134
|
-
newPlan:
|
|
134
|
+
newPlan: SerializedSyncPlan,
|
|
135
135
|
reservedMappings: SingleSyncConfigBucketDefinitionMapping[]
|
|
136
136
|
): SingleSyncConfigBucketDefinitionMapping {
|
|
137
137
|
return this.constructIncrementalMappingWithChanges(compatibleConfigs, newPlan, reservedMappings).mapping;
|
|
@@ -139,7 +139,7 @@ export class SingleSyncConfigBucketDefinitionMapping implements BucketDefinition
|
|
|
139
139
|
|
|
140
140
|
static constructIncrementalMappingWithChanges(
|
|
141
141
|
compatibleConfigs: SerializedSyncConfigWithMapping[],
|
|
142
|
-
newPlan:
|
|
142
|
+
newPlan: SerializedSyncPlan,
|
|
143
143
|
reservedMappings: SingleSyncConfigBucketDefinitionMapping[]
|
|
144
144
|
): IncrementalMappingResult {
|
|
145
145
|
let nextBucketDefinitionId =
|
|
@@ -2,6 +2,7 @@ export * from './bson.js';
|
|
|
2
2
|
export * from './BucketStorage.js';
|
|
3
3
|
export * from './BucketStorageBatch.js';
|
|
4
4
|
export * from './BucketStorageFactory.js';
|
|
5
|
+
export * from './CheckpointChecksumInvalidatedError.js';
|
|
5
6
|
export * from './ChecksumCache.js';
|
|
6
7
|
export * from './ParsedSyncConfigSet.js';
|
|
7
8
|
export * from './PersistedReplicationStream.js';
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
BucketParameterQuerier,
|
|
3
3
|
BucketPriority,
|
|
4
4
|
BucketSource,
|
|
5
|
+
BucketSourceType,
|
|
5
6
|
HydratedSyncConfig,
|
|
6
7
|
mergeBuckets,
|
|
7
8
|
QuerierError,
|
|
@@ -75,6 +76,12 @@ export class BucketChecksumState {
|
|
|
75
76
|
*/
|
|
76
77
|
private lastChecksums: util.ChecksumMap | null = null;
|
|
77
78
|
private lastWriteCheckpoint: bigint | null = null;
|
|
79
|
+
/**
|
|
80
|
+
* The next storage checkpoint diff may be relative to a checksum-invalidated
|
|
81
|
+
* checkpoint that was never sent to the client. Re-check every bucket once
|
|
82
|
+
* instead of applying that diff to the last client-visible checksums.
|
|
83
|
+
*/
|
|
84
|
+
private forceFullChecksumForNextCheckpoint = false;
|
|
78
85
|
/**
|
|
79
86
|
* Once we've sent the first full checkpoint line including all {@link util.Checkpoint.streams} that the user is
|
|
80
87
|
* subscribed to, we keep an index of the stream names to their index in that array.
|
|
@@ -116,6 +123,10 @@ export class BucketChecksumState {
|
|
|
116
123
|
}
|
|
117
124
|
}
|
|
118
125
|
|
|
126
|
+
invalidateChecksumBaseline() {
|
|
127
|
+
this.forceFullChecksumForNextCheckpoint = true;
|
|
128
|
+
}
|
|
129
|
+
|
|
119
130
|
/**
|
|
120
131
|
* Build a new checkpoint line for an underlying storage checkpoint update if any buckets have changed.
|
|
121
132
|
*
|
|
@@ -140,6 +151,7 @@ export class BucketChecksumState {
|
|
|
140
151
|
|
|
141
152
|
const update = await this.parameterState.getCheckpointUpdate(next, tracer);
|
|
142
153
|
const { buckets: allBuckets, updatedBuckets, usedParameterResults } = update;
|
|
154
|
+
const forceFullChecksum = this.forceFullChecksumForNextCheckpoint;
|
|
143
155
|
|
|
144
156
|
/** Set of all buckets in this checkpoint. */
|
|
145
157
|
const bucketDescriptionMap = new Map(allBuckets.map((b) => [b.bucket, b]));
|
|
@@ -164,8 +176,9 @@ export class BucketChecksumState {
|
|
|
164
176
|
const count = bucketsByDefinition.get(definition) ?? 0;
|
|
165
177
|
bucketsByDefinition.set(definition, count + 1);
|
|
166
178
|
}
|
|
167
|
-
|
|
168
|
-
const
|
|
179
|
+
// Only 1 type is allowed per sync config
|
|
180
|
+
const bucketSourceType = this.parameterState.syncRules.bucketSourceDefinitions[0].type;
|
|
181
|
+
const breakdown = formatBucketDefinitionBreakdown(bucketsByDefinition, bucketSourceType);
|
|
169
182
|
errorMessage += breakdown.message;
|
|
170
183
|
logData.buckets_by_definition = breakdown.countsByDefinition;
|
|
171
184
|
|
|
@@ -174,7 +187,7 @@ export class BucketChecksumState {
|
|
|
174
187
|
}
|
|
175
188
|
|
|
176
189
|
let checksumMap: util.ChecksumMap;
|
|
177
|
-
if (updatedBuckets != INVALIDATE_ALL_BUCKETS) {
|
|
190
|
+
if (!forceFullChecksum && updatedBuckets != INVALIDATE_ALL_BUCKETS) {
|
|
178
191
|
if (this.lastChecksums == null) {
|
|
179
192
|
throw new ServiceAssertionError(`Bucket diff received without existing checksums`);
|
|
180
193
|
}
|
|
@@ -248,6 +261,9 @@ export class BucketChecksumState {
|
|
|
248
261
|
diff.updatedBuckets.length == 0
|
|
249
262
|
) {
|
|
250
263
|
// No changes - don't send anything to the client
|
|
264
|
+
if (forceFullChecksum) {
|
|
265
|
+
this.forceFullChecksumForNextCheckpoint = false;
|
|
266
|
+
}
|
|
251
267
|
return null;
|
|
252
268
|
}
|
|
253
269
|
|
|
@@ -402,6 +418,9 @@ export class BucketChecksumState {
|
|
|
402
418
|
this.lastChecksums = checksumMap;
|
|
403
419
|
this.lastWriteCheckpoint = writeCheckpoint;
|
|
404
420
|
this.pendingBucketDownloads = pendingBucketDownloads;
|
|
421
|
+
if (forceFullChecksum) {
|
|
422
|
+
this.forceFullChecksumForNextCheckpoint = false;
|
|
423
|
+
}
|
|
405
424
|
deferredLog();
|
|
406
425
|
},
|
|
407
426
|
|
|
@@ -795,30 +814,36 @@ function logCheckpoint(
|
|
|
795
814
|
}
|
|
796
815
|
|
|
797
816
|
/**
|
|
798
|
-
* Format a breakdown of dynamic
|
|
817
|
+
* Format a breakdown of dynamic buckets by sync stream or legacy bucket definition.
|
|
799
818
|
*
|
|
800
|
-
* Sorts definitions by count (descending), includes the top
|
|
819
|
+
* Sorts definitions by count (descending), includes the top 100, and returns both the
|
|
801
820
|
* formatted message string and the counts record suitable for structured log data.
|
|
802
821
|
*/
|
|
803
|
-
function formatBucketDefinitionBreakdown(
|
|
822
|
+
function formatBucketDefinitionBreakdown(
|
|
823
|
+
bucketsByDefinition: Map<string, number>,
|
|
824
|
+
bucketSourceType: BucketSourceType
|
|
825
|
+
): {
|
|
804
826
|
message: string;
|
|
805
827
|
countsByDefinition: Record<string, number>;
|
|
806
828
|
} {
|
|
807
|
-
|
|
829
|
+
const maxLoggedDefinitions = 100;
|
|
830
|
+
|
|
831
|
+
// Sort definitions by count (descending) and take the largest entries.
|
|
808
832
|
const allSorted = Array.from(bucketsByDefinition.entries()).sort((a, b) => b[1] - a[1]);
|
|
809
|
-
const sortedDefinitions = allSorted.slice(0,
|
|
833
|
+
const sortedDefinitions = allSorted.slice(0, maxLoggedDefinitions);
|
|
810
834
|
|
|
811
|
-
|
|
835
|
+
const sourceLabel = bucketSourceType == BucketSourceType.SYNC_STREAM ? 'sync stream' : 'bucket definition';
|
|
836
|
+
let message = `\nBuckets by ${sourceLabel}:`;
|
|
812
837
|
const countsByDefinition: Record<string, number> = {};
|
|
813
838
|
for (const [definition, count] of sortedDefinitions) {
|
|
814
839
|
message += `\n ${definition}: ${count}`;
|
|
815
840
|
countsByDefinition[definition] = count;
|
|
816
841
|
}
|
|
817
842
|
|
|
818
|
-
if (allSorted.length >
|
|
819
|
-
const remainingResults = allSorted.slice(
|
|
820
|
-
const remainingDefinitions = allSorted.length -
|
|
821
|
-
message += `\n ... and ${remainingResults} more
|
|
843
|
+
if (allSorted.length > maxLoggedDefinitions) {
|
|
844
|
+
const remainingResults = allSorted.slice(maxLoggedDefinitions).reduce((sum, [, count]) => sum + count, 0);
|
|
845
|
+
const remainingDefinitions = allSorted.length - maxLoggedDefinitions;
|
|
846
|
+
message += `\n ... and ${remainingResults} more buckets from ${remainingDefinitions} ${sourceLabel}s`;
|
|
822
847
|
}
|
|
823
848
|
|
|
824
849
|
return { message, countsByDefinition };
|
package/src/sync/sync.ts
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import { JSONBig, JsonContainer } from '@powersync/service-jsonbig';
|
|
2
2
|
import { BucketPriority, HydratedSyncConfig, ResolvedBucket, SqliteJsonValue } from '@powersync/service-sync-rules';
|
|
3
3
|
|
|
4
|
-
import { AbortError } from 'ix/aborterror.js';
|
|
5
|
-
|
|
6
4
|
import * as auth from '../auth/auth-index.js';
|
|
7
5
|
import * as storage from '../storage/storage-index.js';
|
|
8
6
|
import * as util from '../util/util-index.js';
|
|
9
7
|
|
|
10
8
|
import { Logger, logger as defaultLogger } from '@powersync/lib-services-framework';
|
|
9
|
+
import { isBatchEnd } from '../storage/storage-index.js';
|
|
11
10
|
import { mergeAsyncIterables } from '../streams/streams-index.js';
|
|
12
11
|
import { PerformanceTracer, type Span } from '../tracing/PerformanceTracer.js';
|
|
13
12
|
import { BucketChecksumState, CheckpointLine, type SyncCheckpointTraceCategory } from './BucketChecksumState.js';
|
|
14
13
|
import { OperationsSentStats, RequestTracker, statsForBatch } from './RequestTracker.js';
|
|
15
14
|
import { SyncContext } from './SyncContext.js';
|
|
16
|
-
import { TokenStreamOptions, acquireSemaphoreAbortable, settledPromise, tokenStream } from './util.js';
|
|
15
|
+
import { TokenStreamOptions, acquireSemaphoreAbortable, isAbortError, settledPromise, tokenStream } from './util.js';
|
|
17
16
|
|
|
18
17
|
type CheckpointTiming = Record<string, number>;
|
|
19
18
|
|
|
@@ -87,7 +86,7 @@ export async function* streamResponse(
|
|
|
87
86
|
try {
|
|
88
87
|
yield* merged;
|
|
89
88
|
} catch (e) {
|
|
90
|
-
if (e
|
|
89
|
+
if (isAbortError(e)) {
|
|
91
90
|
return;
|
|
92
91
|
} else {
|
|
93
92
|
throw e;
|
|
@@ -149,6 +148,22 @@ async function* streamResponseInner(
|
|
|
149
148
|
const line = await checksumState.buildNextCheckpointLine(next.value, trace.tracer);
|
|
150
149
|
return { done: false, value: { checkpoint: cp, line, trace: line == null ? null : trace } };
|
|
151
150
|
} catch (e) {
|
|
151
|
+
if (e instanceof storage.CheckpointChecksumInvalidatedError) {
|
|
152
|
+
// The checksum was not usable, so buildNextCheckpointLine has not advanced
|
|
153
|
+
// the connection state. Drop this candidate and wait for a checkpoint that
|
|
154
|
+
// is not split by a compaction-produced bucket-data document.
|
|
155
|
+
// This is different from other checkpoint_invalidated cases in that we hit
|
|
156
|
+
// this during checksum calculation, instead of on data read.
|
|
157
|
+
trace.span.end();
|
|
158
|
+
checksumState.invalidateChecksumBaseline();
|
|
159
|
+
logger.info(`checkpoint_invalidated: ${cp.checkpoint}`, {
|
|
160
|
+
reason: 'compacted_before_checkpoint_line',
|
|
161
|
+
bucket: e.bucket,
|
|
162
|
+
checkpoint: cp.checkpoint,
|
|
163
|
+
user_id: tokenPayload.userIdJson
|
|
164
|
+
});
|
|
165
|
+
return { done: false, value: { checkpoint: cp, line: null, trace: null } };
|
|
166
|
+
}
|
|
152
167
|
// Only end the span if we error. If we return normally, we pass ownership on to the caller.
|
|
153
168
|
trace.span.end();
|
|
154
169
|
throw e;
|
|
@@ -220,7 +235,7 @@ async function* streamResponseInner(
|
|
|
220
235
|
while (true) {
|
|
221
236
|
const next = await settledPromise(waitForNewCheckpointLine());
|
|
222
237
|
if (next.status == 'rejected') {
|
|
223
|
-
if (next.reason
|
|
238
|
+
if (isAbortError(next.reason)) {
|
|
224
239
|
checkpointResult = { result: 'invalidated', invalidationReason: 'checkpoint_cancelled' };
|
|
225
240
|
} else {
|
|
226
241
|
checkpointResult = { result: 'invalidated', invalidationReason: 'checkpoint_error' };
|
|
@@ -439,12 +454,23 @@ async function* bucketDataBatch(
|
|
|
439
454
|
// Optimization: Only fetch buckets for which the checksums have changed since the last checkpoint
|
|
440
455
|
// For the first batch, this will be all buckets.
|
|
441
456
|
const filteredBuckets = checkpointLine.getFilteredBucketPositions(bucketsToFetch);
|
|
442
|
-
const dataBatches = storage.getBucketDataBatch(checkpoint, filteredBuckets, {
|
|
443
|
-
|
|
457
|
+
const dataBatches = storage.getBucketDataBatch(checkpoint, filteredBuckets, {
|
|
458
|
+
requestHint,
|
|
459
|
+
// Checkpoint supersession is a cooperative batch handoff. Only abort
|
|
460
|
+
// in-flight storage work when the connection itself is closed.
|
|
461
|
+
signal: abort_connection
|
|
462
|
+
});
|
|
463
|
+
for await (let chunk of dataBatches) {
|
|
444
464
|
// Abort in current batch if the connection is closed
|
|
445
465
|
if (abort_connection.aborted) {
|
|
446
466
|
return null;
|
|
447
467
|
}
|
|
468
|
+
if (isBatchEnd(chunk)) {
|
|
469
|
+
// This replaces any other has_more value, since the batch end is the last chunk.
|
|
470
|
+
has_more = chunk.hasMore;
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
const { chunkData: r, targetOp } = chunk;
|
|
448
474
|
if (r.has_more) {
|
|
449
475
|
has_more = true;
|
|
450
476
|
}
|
package/src/sync/util.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as timers from 'timers/promises';
|
|
|
2
2
|
|
|
3
3
|
import { SemaphoreInterface } from 'async-mutex';
|
|
4
4
|
import { serialize } from 'bson';
|
|
5
|
+
import { AbortError } from 'ix/aborterror.js';
|
|
5
6
|
import * as util from '../util/util-index.js';
|
|
6
7
|
import { RequestTracker } from './RequestTracker.js';
|
|
7
8
|
|
|
@@ -23,6 +24,17 @@ const DEFAULT_TOKEN_STREAM_OPTIONS: TokenStreamOptions = {
|
|
|
23
24
|
expire_warning_period: 20_000
|
|
24
25
|
};
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Recognize both ix's AbortError and native abort errors, which Node represents
|
|
29
|
+
* as DOMExceptions with the name "AbortError".
|
|
30
|
+
*/
|
|
31
|
+
export function isAbortError(error: unknown): boolean {
|
|
32
|
+
return (
|
|
33
|
+
error instanceof AbortError ||
|
|
34
|
+
(typeof error == 'object' && error != null && 'name' in error && error.name === 'AbortError')
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
/**
|
|
27
39
|
* An iterator that periodically yields token and optionally keepalive events, and returns once the
|
|
28
40
|
* provided token expiry is reached.
|
|
@@ -3,12 +3,13 @@ import { WriteCheckpointBatcher } from './write-checkpoint-batcher.js';
|
|
|
3
3
|
export interface CreateWriteCheckpointOptions {
|
|
4
4
|
userId: string | undefined;
|
|
5
5
|
clientId: string | undefined;
|
|
6
|
+
checkpointRequestId?: bigint;
|
|
6
7
|
batcher: WriteCheckpointBatcher;
|
|
7
8
|
}
|
|
8
9
|
export async function createWriteCheckpoint(options: CreateWriteCheckpointOptions) {
|
|
9
10
|
const full_user_id = checkpointUserId(options.userId, options.clientId);
|
|
10
11
|
|
|
11
|
-
return options.batcher.enqueue(full_user_id);
|
|
12
|
+
return options.batcher.enqueue(full_user_id, options.checkpointRequestId);
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export function checkpointUserId(user_id: string | undefined, client_id: string | undefined) {
|