@powersync/service-core 1.23.3 → 1.25.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 +45 -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/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/SourceEntity.d.ts +7 -2
- package/dist/storage/SourceTable.d.ts +24 -1
- package/dist/storage/SourceTable.js +34 -6
- package/dist/storage/SourceTable.js.map +1 -1
- package/dist/storage/SourceTableReconciler.d.ts +83 -0
- package/dist/storage/SourceTableReconciler.js +95 -0
- package/dist/storage/SourceTableReconciler.js.map +1 -0
- package/dist/storage/SyncRulesBucketStorage.d.ts +32 -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 +2 -0
- package/dist/storage/storage-index.js +2 -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/utils.d.ts +1 -1
- package/dist/util/utils.js +1 -1
- package/dist/util/utils.js.map +1 -1
- 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/storage/BucketStorageBatch.ts +4 -1
- package/src/storage/BucketStorageFactory.ts +2 -2
- package/src/storage/CheckpointChecksumInvalidatedError.ts +17 -0
- package/src/storage/SourceEntity.ts +6 -2
- package/src/storage/SourceTable.ts +51 -7
- package/src/storage/SourceTableReconciler.ts +188 -0
- package/src/storage/SyncRulesBucketStorage.ts +38 -1
- package/src/storage/WriteCheckpointAPI.ts +108 -3
- package/src/storage/implementation/BucketDefinitionMapping.ts +4 -4
- package/src/storage/storage-index.ts +2 -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/utils.ts +1 -1
- 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/source-table-reconciler.test.ts +244 -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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { ServiceAssertionError } from '@powersync/lib-services-framework';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
import { JsonValue, SourceEntityDescriptor } from './SourceEntity.js';
|
|
4
|
+
import { SourceTable, SourceTableCandidate, SourceTableId, sourceTableIdEquals } from './SourceTable.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A source connector's classification of overlapping persisted tables.
|
|
8
|
+
*/
|
|
9
|
+
export interface SourceTableCandidateResolution {
|
|
10
|
+
/**
|
|
11
|
+
* Records storage can reuse. Copies may include updated source metadata.
|
|
12
|
+
*/
|
|
13
|
+
compatibleTables: ReadonlyArray<SourceTableCandidate>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Records that cannot be reused. Every candidate must appear in exactly one result list.
|
|
17
|
+
*/
|
|
18
|
+
incompatibleTables: ReadonlyArray<SourceTableCandidate>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Values for records storage creates during this resolution.
|
|
22
|
+
*/
|
|
23
|
+
newTableValues: SourceTableCreateValues;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SourceTableCreateValues {
|
|
27
|
+
/**
|
|
28
|
+
* Source metadata for new records. Null means no metadata.
|
|
29
|
+
*/
|
|
30
|
+
sourceMetadata: JsonValue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Input to a source-owned reconciliation callback. The callback may run inside a storage
|
|
35
|
+
* transaction, so it must not mutate storage or perform slow external work.
|
|
36
|
+
*/
|
|
37
|
+
export interface SourceTableCandidateReconcilerInput {
|
|
38
|
+
/**
|
|
39
|
+
* Source entity being resolved.
|
|
40
|
+
*/
|
|
41
|
+
source: SourceEntityDescriptor;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Persisted tables overlapping by name or object id.
|
|
45
|
+
*/
|
|
46
|
+
candidates: ReadonlyArray<SourceTableCandidate>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type SourceTableCandidateReconciler = (
|
|
50
|
+
input: SourceTableCandidateReconcilerInput
|
|
51
|
+
) => SourceTableCandidateResolution | Promise<SourceTableCandidateResolution>;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compare replica-id columns in order.
|
|
55
|
+
*/
|
|
56
|
+
export function sameReplicaIdColumns(
|
|
57
|
+
left: SourceTableCandidate['replicaIdColumns'],
|
|
58
|
+
right: SourceEntityDescriptor
|
|
59
|
+
): boolean {
|
|
60
|
+
const target = right.replicaIdColumns;
|
|
61
|
+
return (
|
|
62
|
+
left.length == target.length &&
|
|
63
|
+
left.every(
|
|
64
|
+
(column, index) =>
|
|
65
|
+
column.name == target[index].name && column.type == target[index].type && column.typeId == target[index].typeId
|
|
66
|
+
)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Compare the shared source-table identity fields.
|
|
72
|
+
*/
|
|
73
|
+
export function sourceIdentityCompatible(source: SourceEntityDescriptor, candidate: SourceTableCandidate): boolean {
|
|
74
|
+
return (
|
|
75
|
+
candidate.schema == source.schema &&
|
|
76
|
+
candidate.name == source.name &&
|
|
77
|
+
(source.objectId == null || candidate.objectId == source.objectId) &&
|
|
78
|
+
sameReplicaIdColumns(candidate.replicaIdColumns, source)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Default identity-based reconciliation for connectors without source-specific metadata.
|
|
84
|
+
*/
|
|
85
|
+
export const defaultSourceTableReconciler: SourceTableCandidateReconciler = ({ source, candidates }) => {
|
|
86
|
+
const compatibleTables: SourceTableCandidate[] = [];
|
|
87
|
+
const incompatibleTables: SourceTableCandidate[] = [];
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
if (sourceIdentityCompatible(source, candidate)) {
|
|
90
|
+
compatibleTables.push(candidate);
|
|
91
|
+
} else {
|
|
92
|
+
incompatibleTables.push(candidate);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { compatibleTables, incompatibleTables, newTableValues: { sourceMetadata: null } };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check that every candidate was classified exactly once.
|
|
100
|
+
*/
|
|
101
|
+
export function validateSourceTableCandidateResolution(
|
|
102
|
+
candidates: ReadonlyArray<SourceTableCandidate>,
|
|
103
|
+
resolution: SourceTableCandidateResolution
|
|
104
|
+
): void {
|
|
105
|
+
const classifiedTables = [...resolution.compatibleTables, ...resolution.incompatibleTables];
|
|
106
|
+
|
|
107
|
+
for (const candidate of candidates) {
|
|
108
|
+
const classifications = classifiedTables.filter((table) => sourceTableIdEquals(table.id, candidate.id));
|
|
109
|
+
if (classifications.length !== 1) {
|
|
110
|
+
throw new ServiceAssertionError(
|
|
111
|
+
`Source table candidate ${candidate.id.toString()} must be classified exactly once, got ${classifications.length}`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const table of classifiedTables) {
|
|
117
|
+
if (!candidates.some((candidate) => sourceTableIdEquals(candidate.id, table.id))) {
|
|
118
|
+
throw new ServiceAssertionError(`Source table reconciliation returned unknown candidate ${table.id.toString()}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A source-metadata update to persist.
|
|
125
|
+
*/
|
|
126
|
+
export interface SourceTableMetadataUpdate {
|
|
127
|
+
id: SourceTableId;
|
|
128
|
+
sourceMetadata: JsonValue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Rebuild a resolution from storage-owned tables, applying only reconciler-owned metadata to
|
|
133
|
+
* compatible tables. All other mutable table state comes from storage.
|
|
134
|
+
*
|
|
135
|
+
* Reconciler candidates are typed as read-only, but TypeScript types provide no runtime protection:
|
|
136
|
+
* callback code can cast a cloned candidate and mutate it. Rematerializing by id ensures those
|
|
137
|
+
* mutations are not trusted even when the compile-time boundary is bypassed.
|
|
138
|
+
*/
|
|
139
|
+
export function materializeSourceTableResolution(
|
|
140
|
+
tables: ReadonlyArray<SourceTable>,
|
|
141
|
+
resolution: SourceTableCandidateResolution
|
|
142
|
+
): MaterializedSourceTableResolution {
|
|
143
|
+
const findTable = (candidate: SourceTableCandidate): SourceTable => {
|
|
144
|
+
const table = tables.find((table) => sourceTableIdEquals(table.id, candidate.id));
|
|
145
|
+
if (table == null) {
|
|
146
|
+
throw new ServiceAssertionError(`Source table candidate ${candidate.id.toString()} was not persisted`);
|
|
147
|
+
}
|
|
148
|
+
return table;
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
compatibleTables: resolution.compatibleTables.map((candidate) =>
|
|
152
|
+
findTable(candidate).withSourceMetadata(candidate.sourceMetadata)
|
|
153
|
+
),
|
|
154
|
+
incompatibleTables: resolution.incompatibleTables.map(findTable),
|
|
155
|
+
newTableValues: resolution.newTableValues
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface MaterializedSourceTableResolution {
|
|
160
|
+
compatibleTables: SourceTable[];
|
|
161
|
+
incompatibleTables: SourceTable[];
|
|
162
|
+
newTableValues: SourceTableCreateValues;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Return source-metadata changes from compatible candidates, comparing metadata by value against
|
|
167
|
+
* the original storage-owned tables. The reconciler may mutate its isolated candidate clones, so
|
|
168
|
+
* those clones cannot be used as the persisted baseline.
|
|
169
|
+
*/
|
|
170
|
+
export function diffSourceTableUpdates(
|
|
171
|
+
persistedTables: ReadonlyArray<SourceTable>,
|
|
172
|
+
resolution: SourceTableCandidateResolution
|
|
173
|
+
): SourceTableMetadataUpdate[] {
|
|
174
|
+
const updates: SourceTableMetadataUpdate[] = [];
|
|
175
|
+
for (const resolvedTable of resolution.compatibleTables) {
|
|
176
|
+
const persistedTable = persistedTables.find((table) => sourceTableIdEquals(table.id, resolvedTable.id));
|
|
177
|
+
if (persistedTable == null) {
|
|
178
|
+
throw new ServiceAssertionError(
|
|
179
|
+
`Source table reconciliation returned unknown candidate ${resolvedTable.id.toString()}`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (isDeepStrictEqual(persistedTable.sourceMetadata, resolvedTable.sourceMetadata)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
updates.push({ id: resolvedTable.id, sourceMetadata: resolvedTable.sourceMetadata });
|
|
186
|
+
}
|
|
187
|
+
return updates;
|
|
188
|
+
}
|
|
@@ -14,6 +14,7 @@ import { ParsedSyncConfigSet } from './ParsedSyncConfigSet.js';
|
|
|
14
14
|
import { ParseSyncConfigOptions } from './PersistedSyncConfigContent.js';
|
|
15
15
|
import { SourceEntityDescriptor } from './SourceEntity.js';
|
|
16
16
|
import { SourceTable } from './SourceTable.js';
|
|
17
|
+
import { SourceTableCandidateReconciler } from './SourceTableReconciler.js';
|
|
17
18
|
import { StorageVersionConfig } from './StorageVersionConfig.js';
|
|
18
19
|
import { SyncStorageWriteCheckpointAPI } from './WriteCheckpointAPI.js';
|
|
19
20
|
|
|
@@ -133,6 +134,13 @@ export interface SyncRulesBucketStorage
|
|
|
133
134
|
* 1. Separate buckets.
|
|
134
135
|
* 2. Limit the size of each individual chunk according to options.batchSizeLimitBytes.
|
|
135
136
|
*
|
|
137
|
+
* The batch may not contain all data for the checkpoint, if the checkpoint is large. The caller must
|
|
138
|
+
* continue querying if either:
|
|
139
|
+
* 1. The last chunk for any bucket has has_more = true.
|
|
140
|
+
* 2. A SyncBucketDataBatchEnd is returned with hasMore = true.
|
|
141
|
+
*
|
|
142
|
+
* The first check can be skipped if a SyncBucketDataBatchEnd is returned with hasMore = false.
|
|
143
|
+
*
|
|
136
144
|
* @param checkpoint the checkpoint
|
|
137
145
|
* @param dataBuckets current bucket states
|
|
138
146
|
* @param options batch size options
|
|
@@ -141,7 +149,7 @@ export interface SyncRulesBucketStorage
|
|
|
141
149
|
checkpoint: ReplicationCheckpoint,
|
|
142
150
|
dataBuckets: BucketDataRequest[],
|
|
143
151
|
options?: BucketDataBatchOptions
|
|
144
|
-
): AsyncIterable<SyncBucketDataChunk>;
|
|
152
|
+
): AsyncIterable<SyncBucketDataChunk | SyncBucketDataBatchEnd>;
|
|
145
153
|
|
|
146
154
|
/**
|
|
147
155
|
* Compute checksums for a given list of buckets.
|
|
@@ -205,6 +213,11 @@ export interface ResolveTablesOptions {
|
|
|
205
213
|
* Source table or collection metadata discovered during snapshot or streaming.
|
|
206
214
|
*/
|
|
207
215
|
source: SourceEntityDescriptor;
|
|
216
|
+
/**
|
|
217
|
+
* Classifies overlapping persisted tables. Defaults to identity-based reconciliation.
|
|
218
|
+
* This may run inside a storage transaction and must not mutate storage.
|
|
219
|
+
*/
|
|
220
|
+
reconcileSourceTables?: SourceTableCandidateReconciler;
|
|
208
221
|
/**
|
|
209
222
|
* For tests only - custom id generator for stable ids.
|
|
210
223
|
*/
|
|
@@ -308,6 +321,13 @@ export interface CompactOptions {
|
|
|
308
321
|
|
|
309
322
|
compactParameterData?: boolean;
|
|
310
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Delete client-requested write checkpoints created before this time.
|
|
326
|
+
*
|
|
327
|
+
* Generated write checkpoints are not affected.
|
|
328
|
+
*/
|
|
329
|
+
deleteCheckpointRequestsBefore?: Date;
|
|
330
|
+
|
|
311
331
|
/** Minimum of 2 */
|
|
312
332
|
clearBatchLimit?: number;
|
|
313
333
|
|
|
@@ -396,6 +416,9 @@ export interface TerminateOptions extends ClearStorageOptions {
|
|
|
396
416
|
export interface BucketDataBatchOptions {
|
|
397
417
|
requestHint?: BucketRequestHint;
|
|
398
418
|
|
|
419
|
+
/** Abort any in-progress work for this batch, including object-storage downloads. */
|
|
420
|
+
signal?: AbortSignal;
|
|
421
|
+
|
|
399
422
|
/** Limit number of documents returned. Defaults to 1000. */
|
|
400
423
|
limit?: number;
|
|
401
424
|
|
|
@@ -416,6 +439,20 @@ export interface SyncBucketDataChunk {
|
|
|
416
439
|
targetOp: util.InternalOpId | null;
|
|
417
440
|
}
|
|
418
441
|
|
|
442
|
+
export interface SyncBucketDataBatchEnd {
|
|
443
|
+
/**
|
|
444
|
+
* True if there may be more data for this checkpoint, and the caller should continue querying.
|
|
445
|
+
*
|
|
446
|
+
* This is different from `SyncBucketDataChunk.has_more`, which is per-bucket. This is a global signal for the
|
|
447
|
+
* entire request, and may be true even if there is no returned chunk with has_more: true.
|
|
448
|
+
*/
|
|
449
|
+
hasMore: boolean;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function isBatchEnd(chunk: SyncBucketDataChunk | SyncBucketDataBatchEnd): chunk is SyncBucketDataBatchEnd {
|
|
453
|
+
return (chunk as SyncBucketDataBatchEnd).hasMore !== undefined;
|
|
454
|
+
}
|
|
455
|
+
|
|
419
456
|
export interface ReplicationCheckpoint {
|
|
420
457
|
readonly checkpoint: util.InternalOpId;
|
|
421
458
|
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';
|
|
@@ -12,6 +13,7 @@ export * from './ReplicationLock.js';
|
|
|
12
13
|
export * from './ReportStorage.js';
|
|
13
14
|
export * from './SourceEntity.js';
|
|
14
15
|
export * from './SourceTable.js';
|
|
16
|
+
export * from './SourceTableReconciler.js';
|
|
15
17
|
export * from './storage-metrics.js';
|
|
16
18
|
export * from './StorageEngine.js';
|
|
17
19
|
export * from './StorageProvider.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
|
}
|