@powersync/service-module-mongodb-storage 0.23.2 → 0.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 +36 -0
- package/LICENSE +201 -67
- package/dist/storage/MongoBucketStorage.d.ts +1 -0
- package/dist/storage/MongoBucketStorage.js +3 -0
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +60 -39
- package/dist/storage/implementation/MongoBucketBatch.js +123 -296
- package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
- package/dist/storage/implementation/MongoOpIdAllocator.d.ts +6 -1
- package/dist/storage/implementation/MongoOpIdAllocator.js +12 -5
- package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -1
- package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +7 -0
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js +28 -2
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
- package/dist/storage/implementation/MongoReplicationOptions.d.ts +27 -0
- package/dist/storage/implementation/MongoReplicationOptions.js +26 -0
- package/dist/storage/implementation/MongoReplicationOptions.js.map +1 -0
- package/dist/storage/implementation/MongoSyncBucketStorage.js +2 -3
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoSyncRulesLock.d.ts +15 -2
- package/dist/storage/implementation/MongoSyncRulesLock.js +36 -14
- package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
- package/dist/storage/implementation/common/BatchOperationBuffer.d.ts +79 -0
- package/dist/storage/implementation/common/BatchOperationBuffer.js +286 -0
- package/dist/storage/implementation/common/BatchOperationBuffer.js.map +1 -0
- package/dist/storage/implementation/common/PersistedBatch.d.ts +10 -103
- package/dist/storage/implementation/common/PersistedBatch.js +1 -291
- package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
- package/dist/storage/implementation/common/PreparedPublication.d.ts +43 -0
- package/dist/storage/implementation/common/PreparedPublication.js +2 -0
- package/dist/storage/implementation/common/PreparedPublication.js.map +1 -0
- package/dist/storage/implementation/common/SourceRecordStore.d.ts +0 -2
- package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +17 -13
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js +259 -18
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +21 -15
- package/dist/storage/implementation/v1/PersistedBatchV1.js +58 -24
- package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/SourceRecordStoreV1.d.ts +0 -2
- package/dist/storage/implementation/v1/SourceRecordStoreV1.js +0 -3
- package/dist/storage/implementation/v1/SourceRecordStoreV1.js.map +1 -1
- package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +25 -14
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js +350 -23
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +1 -0
- package/dist/storage/implementation/v3/MongoCompactorV3.js +10 -4
- package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +39 -28
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
- package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +46 -24
- package/dist/storage/implementation/v3/PersistedBatchV3.js +159 -83
- package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +12 -0
- package/dist/storage/implementation/v3/SourceRecordStoreV3.js +41 -13
- package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +4 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +4 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +27 -2
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +61 -5
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
- package/package.json +9 -10
- package/src/storage/MongoBucketStorage.ts +4 -0
- package/src/storage/implementation/MongoBucketBatch.ts +185 -392
- package/src/storage/implementation/MongoOpIdAllocator.ts +12 -5
- package/src/storage/implementation/MongoParsedSyncConfigSet.ts +40 -1
- package/src/storage/implementation/MongoReplicationOptions.ts +28 -0
- package/src/storage/implementation/MongoSyncBucketStorage.ts +2 -3
- package/src/storage/implementation/MongoSyncRulesLock.ts +39 -17
- package/src/storage/implementation/common/BatchOperationBuffer.ts +353 -0
- package/src/storage/implementation/common/PersistedBatch.ts +12 -390
- package/src/storage/implementation/common/PreparedPublication.ts +49 -0
- package/src/storage/implementation/common/SourceRecordStore.ts +0 -2
- package/src/storage/implementation/v1/MongoBucketBatchV1.ts +339 -27
- package/src/storage/implementation/v1/PersistedBatchV1.ts +74 -32
- package/src/storage/implementation/v1/SourceRecordStoreV1.ts +0 -5
- package/src/storage/implementation/v3/MongoBucketBatchV3.ts +430 -32
- package/src/storage/implementation/v3/MongoCompactorV3.ts +13 -4
- package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +43 -29
- package/src/storage/implementation/v3/PersistedBatchV3.ts +209 -108
- package/src/storage/implementation/v3/SourceRecordStoreV3.ts +45 -15
- package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +9 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +79 -5
- package/test/src/checkpoint_cleanup.test.ts +75 -0
- package/test/src/concurrent_writers.test.ts +127 -53
- package/test/src/helpers/PublicationCommandMonitor.ts +119 -0
- package/test/src/helpers/concurrentWriter.mjs +2 -2
- package/test/src/invalid_sync_config.test.ts +9 -0
- package/test/src/object_storage_lifecycle.test.ts +72 -2
- package/test/src/prepared_publication.test.ts +388 -0
- package/test/src/replication_preparation.test.ts +829 -0
- package/test/src/replication_publication_baseline.test.ts +203 -0
- package/test/src/replication_publication_batching.test.ts +212 -0
- package/test/src/replication_uploads.test.ts +393 -0
- package/test/src/source_table_config.test.ts +4 -4
- package/test/src/storage_object_storage_inline_threshold.test.ts +1 -1
- package/test/src/storage_s3_compaction_lifecycle.test.ts +93 -1
- package/test/src/storage_sync.test.ts +83 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -5,11 +5,23 @@ import { BucketDefinitionMapping, storage } from '@powersync/service-core';
|
|
|
5
5
|
import { EvaluatedParameters, EvaluatedRow } from '@powersync/service-sync-rules';
|
|
6
6
|
import * as bson from 'bson';
|
|
7
7
|
import { retryOnMongoMaxTimeMSExpired } from '../../../utils/util.js';
|
|
8
|
+
import { REPLICATION_READ_OPTIONS, REPLICATION_WRITE_OPTIONS } from '../MongoReplicationOptions.js';
|
|
8
9
|
import { cacheKey } from '../OperationBatch.js';
|
|
9
10
|
import { LoadedSourceRecord, SourceRecordLookupEntry, SourceRecordStore } from '../common/SourceRecordStore.js';
|
|
10
11
|
import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
|
|
11
12
|
import { CurrentDataDocumentV3, serializeParameterLookup, SourceTableDocumentV3 } from './models.js';
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Source-record access for v3/v4 preparation and post-commit cleanup.
|
|
16
|
+
*
|
|
17
|
+
* The loadSizes(), loadDocuments() and loadTruncateBatch() methods are preparation
|
|
18
|
+
* reads: callers must hold the lease's writer mutex and use its shared causal
|
|
19
|
+
* session outside any transaction. These reads always request primary/majority
|
|
20
|
+
* consistency, so they observe earlier writes in that session. Keep the mutex
|
|
21
|
+
* held through publication to prevent another local writer from changing the
|
|
22
|
+
* source records between preparation and commit. Publication transactions write
|
|
23
|
+
* the prepared result without calling these read methods again.
|
|
24
|
+
*/
|
|
13
25
|
export class SourceRecordStoreV3 implements SourceRecordStore {
|
|
14
26
|
constructor(
|
|
15
27
|
private readonly db: VersionedPowerSyncMongoV3,
|
|
@@ -60,7 +72,7 @@ export class SourceRecordStoreV3 implements SourceRecordStore {
|
|
|
60
72
|
|
|
61
73
|
async loadSizes(session: mongo.ClientSession, entries: SourceRecordLookupEntry[]): Promise<Map<string, number>> {
|
|
62
74
|
const sizes = new Map<string, number>();
|
|
63
|
-
for (const
|
|
75
|
+
for (const { sourceTableId, replicaIds } of this.groupEntries(entries)) {
|
|
64
76
|
const filter = {
|
|
65
77
|
_id: { $in: replicaIds as any[] }
|
|
66
78
|
} as unknown as mongo.Filter<CurrentDataDocumentV3>;
|
|
@@ -78,7 +90,7 @@ export class SourceRecordStoreV3 implements SourceRecordStore {
|
|
|
78
90
|
}
|
|
79
91
|
}
|
|
80
92
|
],
|
|
81
|
-
{ session }
|
|
93
|
+
{ session, ...REPLICATION_READ_OPTIONS }
|
|
82
94
|
);
|
|
83
95
|
for await (const doc of sizeCursor.stream()) {
|
|
84
96
|
sizes.set(cacheKey(sourceTableId, doc._id), doc.size);
|
|
@@ -94,13 +106,15 @@ export class SourceRecordStoreV3 implements SourceRecordStore {
|
|
|
94
106
|
): Promise<Map<string, LoadedSourceRecord>> {
|
|
95
107
|
const documents = new Map<string, LoadedSourceRecord>();
|
|
96
108
|
const projection = idsOnly ? { _id: 1 } : undefined;
|
|
97
|
-
for (const
|
|
109
|
+
for (const { sourceTableId, replicaIds } of this.groupEntries(entries)) {
|
|
98
110
|
const filter = {
|
|
99
111
|
_id: { $in: replicaIds as any[] }
|
|
100
112
|
} as unknown as mongo.Filter<CurrentDataDocumentV3>;
|
|
101
|
-
const cursor = this.db
|
|
102
|
-
|
|
103
|
-
|
|
113
|
+
const cursor = this.db.sourceRecords(this.replicationStreamId, sourceTableId).find(filter, {
|
|
114
|
+
session,
|
|
115
|
+
projection,
|
|
116
|
+
...REPLICATION_READ_OPTIONS
|
|
117
|
+
});
|
|
104
118
|
for await (const doc of cursor.stream()) {
|
|
105
119
|
const loaded = this.createLoadedDocument(
|
|
106
120
|
sourceTableId,
|
|
@@ -131,7 +145,8 @@ export class SourceRecordStoreV3 implements SourceRecordStore {
|
|
|
131
145
|
lookups: 1
|
|
132
146
|
},
|
|
133
147
|
limit,
|
|
134
|
-
session
|
|
148
|
+
session,
|
|
149
|
+
...REPLICATION_READ_OPTIONS
|
|
135
150
|
}
|
|
136
151
|
);
|
|
137
152
|
return (await cursor.toArray()).map((doc) =>
|
|
@@ -182,7 +197,9 @@ export class SourceRecordStoreV3 implements SourceRecordStore {
|
|
|
182
197
|
}
|
|
183
198
|
|
|
184
199
|
if (sourceTableUpdates.length > 0) {
|
|
185
|
-
await this.db
|
|
200
|
+
await this.db
|
|
201
|
+
.sourceTables(this.replicationStreamId)
|
|
202
|
+
.bulkWrite(sourceTableUpdates, { ordered: false, ...REPLICATION_WRITE_OPTIONS });
|
|
186
203
|
}
|
|
187
204
|
if (deletedCount > 0) {
|
|
188
205
|
logger.info(`Cleaned up ${deletedCount} pending delete current_data records for checkpoint ${lastCheckpoint}`);
|
|
@@ -195,6 +212,8 @@ export class SourceRecordStoreV3 implements SourceRecordStore {
|
|
|
195
212
|
lastCheckpoint: bigint,
|
|
196
213
|
logger: Logger
|
|
197
214
|
) {
|
|
215
|
+
// A previous owner's in-flight cleanup must commute with a new
|
|
216
|
+
// publisher: live upserts clear pending_delete and never match.
|
|
198
217
|
return retryOnMongoMaxTimeMSExpired(
|
|
199
218
|
() =>
|
|
200
219
|
collection.deleteMany(
|
|
@@ -202,7 +221,8 @@ export class SourceRecordStoreV3 implements SourceRecordStore {
|
|
|
202
221
|
pending_delete: { $exists: true, $lte: lastCheckpoint }
|
|
203
222
|
},
|
|
204
223
|
{
|
|
205
|
-
maxTimeMS: lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS
|
|
224
|
+
maxTimeMS: lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS,
|
|
225
|
+
...REPLICATION_WRITE_OPTIONS
|
|
206
226
|
}
|
|
207
227
|
),
|
|
208
228
|
{
|
|
@@ -214,13 +234,23 @@ export class SourceRecordStoreV3 implements SourceRecordStore {
|
|
|
214
234
|
);
|
|
215
235
|
}
|
|
216
236
|
|
|
217
|
-
|
|
218
|
-
|
|
237
|
+
/** Group table IDs by value and deduplicate replica IDs, preserving first-seen order. */
|
|
238
|
+
private groupEntries(
|
|
239
|
+
entries: SourceRecordLookupEntry[]
|
|
240
|
+
): { sourceTableId: bson.ObjectId; replicaIds: storage.ReplicaId[] }[] {
|
|
241
|
+
const grouped = new Map<string, { sourceTableId: bson.ObjectId; replicas: Map<string, storage.ReplicaId> }>();
|
|
219
242
|
for (const entry of entries) {
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
243
|
+
const key = entry.sourceTableId.toHexString();
|
|
244
|
+
let group = grouped.get(key);
|
|
245
|
+
if (group == null) {
|
|
246
|
+
group = { sourceTableId: entry.sourceTableId, replicas: new Map() };
|
|
247
|
+
grouped.set(key, group);
|
|
248
|
+
}
|
|
249
|
+
group.replicas.set(cacheKey(entry.sourceTableId, entry.replicaId), entry.replicaId);
|
|
223
250
|
}
|
|
224
|
-
return grouped
|
|
251
|
+
return [...grouped.values()].map(({ sourceTableId, replicas }) => ({
|
|
252
|
+
sourceTableId,
|
|
253
|
+
replicaIds: [...replicas.values()]
|
|
254
|
+
}));
|
|
225
255
|
}
|
|
226
256
|
}
|
|
@@ -12,6 +12,15 @@ export class BucketDataObjectStorage {
|
|
|
12
12
|
options?: ObjectStorageOperationOptions
|
|
13
13
|
): Promise<{ fileSize: number }> {
|
|
14
14
|
const bsonBuffer = bson.serialize({ ops });
|
|
15
|
+
return this.storeSerialized(path, bsonBuffer, options);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Store an already sealed BSON { ops } payload without serializing it again. */
|
|
19
|
+
async storeSerialized(
|
|
20
|
+
path: string,
|
|
21
|
+
bsonBuffer: Uint8Array,
|
|
22
|
+
options?: ObjectStorageOperationOptions
|
|
23
|
+
): Promise<{ fileSize: number }> {
|
|
15
24
|
await this.storage.put(path, bsonBuffer, { contentType: 'application/bson', contentEncoding: null }, options);
|
|
16
25
|
return { fileSize: bsonBuffer.byteLength };
|
|
17
26
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
1
2
|
import { Logger } from '@powersync/lib-services-framework';
|
|
2
3
|
import * as bson from 'bson';
|
|
3
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { REPLICATION_WRITE_OPTIONS } from '../../MongoReplicationOptions.js';
|
|
4
6
|
import { MongoWriteBatch } from '../../MongoWriteBatch.js';
|
|
5
7
|
import { ObjectStorageDeletionMarker } from '../models.js';
|
|
6
8
|
import { VersionedPowerSyncMongoV3 } from '../VersionedPowerSyncMongoV3.js';
|
|
@@ -8,6 +10,12 @@ import { BucketDataObjectStorage } from './BucketDataObjectStorage.js';
|
|
|
8
10
|
import { ObjectStorage, ObjectStorageOperationOptions } from './ObjectStorage.js';
|
|
9
11
|
|
|
10
12
|
export const OBJECT_STORAGE_UPLOAD_LEASE_MS = 60 * 60 * 1000;
|
|
13
|
+
/**
|
|
14
|
+
* Leave time for the bounded publication transaction and clock skew before
|
|
15
|
+
* cleanup can select a marker. Publication must finish within this margin after
|
|
16
|
+
* its eligibility check; checked marker deletion alone cannot protect an object
|
|
17
|
+
* from a cleanup worker that already selected an expired marker.
|
|
18
|
+
*/
|
|
11
19
|
export const OBJECT_STORAGE_PUBLICATION_SAFETY_MARGIN_MS = 2 * 60 * 1000;
|
|
12
20
|
export const OBJECT_STORAGE_REFERENCE_GRACE_MS = 15 * 60 * 1000;
|
|
13
21
|
/**
|
|
@@ -23,6 +31,11 @@ export interface PreparedObjectStorageUpload {
|
|
|
23
31
|
deleteAfter: Date;
|
|
24
32
|
}
|
|
25
33
|
|
|
34
|
+
export interface ObjectStorageUploadPayload {
|
|
35
|
+
upload: PreparedObjectStorageUpload;
|
|
36
|
+
payload: Uint8Array;
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
/**
|
|
27
40
|
* Coordinates the MongoDB outbox used to make object-storage changes recoverable.
|
|
28
41
|
*
|
|
@@ -67,18 +80,26 @@ export class ObjectStorageLifecycle {
|
|
|
67
80
|
* Indicate that files will be uploaded to S3.
|
|
68
81
|
*
|
|
69
82
|
* These markers are expected to be removed using publishUploads(). If that does not happen within
|
|
70
|
-
* the grace period, the files will be deleted.
|
|
83
|
+
* the grace period, the files will be deleted. Registration must be majority
|
|
84
|
+
* acknowledged before any PUT starts: losing a marker on failover would lose
|
|
85
|
+
* the only cleanup obligation for an unpublished object. A failed/ambiguous
|
|
86
|
+
* insert must therefore prevent uploads, even if some markers were inserted.
|
|
71
87
|
*/
|
|
72
|
-
async prepareUploads(
|
|
88
|
+
async prepareUploads(
|
|
89
|
+
paths: string[],
|
|
90
|
+
now = new Date(),
|
|
91
|
+
session?: mongo.ClientSession
|
|
92
|
+
): Promise<PreparedObjectStorageUpload[]> {
|
|
73
93
|
const deleteAfter = new Date(now.getTime() + OBJECT_STORAGE_UPLOAD_LEASE_MS);
|
|
74
94
|
const uploads = paths.map((path) => ({ markerId: new bson.ObjectId(), path, deleteAfter }));
|
|
75
|
-
if (uploads.length) {
|
|
95
|
+
if (uploads.length > 0) {
|
|
76
96
|
await this.db.pendingObjectStorageDeletes(this.replicationStreamId).insertMany(
|
|
77
97
|
uploads.map((upload) => ({
|
|
78
98
|
_id: upload.markerId,
|
|
79
99
|
path: upload.path,
|
|
80
100
|
delete_after: upload.deleteAfter
|
|
81
|
-
}))
|
|
101
|
+
})),
|
|
102
|
+
{ ...REPLICATION_WRITE_OPTIONS, session }
|
|
82
103
|
);
|
|
83
104
|
}
|
|
84
105
|
return uploads;
|
|
@@ -88,6 +109,57 @@ export class ObjectStorageLifecycle {
|
|
|
88
109
|
return now.getTime() < upload.deleteAfter.getTime() - OBJECT_STORAGE_PUBLICATION_SAFETY_MARGIN_MS;
|
|
89
110
|
}
|
|
90
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Upload a durably registered generation, joining every started PUT on failure.
|
|
114
|
+
* The shared adapter limits concurrency; its queue must observe the same signal
|
|
115
|
+
* as an active request so no queued PUT can start after the protection deadline.
|
|
116
|
+
*
|
|
117
|
+
* Failed/ambiguous PUTs retain their original markers. S3's bounded request
|
|
118
|
+
* timeouts are far shorter than the one-hour orphan grace, including when a
|
|
119
|
+
* response is lost or the process dies. Cancellation does not prove an object
|
|
120
|
+
* is absent, so never delete an object or its marker on upload failure.
|
|
121
|
+
*/
|
|
122
|
+
async storeUploads(uploads: ObjectStorageUploadPayload[], options?: ObjectStorageOperationOptions): Promise<void> {
|
|
123
|
+
if (uploads.length === 0) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const deadline =
|
|
127
|
+
Math.min(...uploads.map(({ upload }) => upload.deleteAfter.getTime())) -
|
|
128
|
+
OBJECT_STORAGE_PUBLICATION_SAFETY_MARGIN_MS;
|
|
129
|
+
const remaining = deadline - Date.now();
|
|
130
|
+
if (remaining <= 0) {
|
|
131
|
+
throw new Error('Object storage upload lease expired before PUT');
|
|
132
|
+
}
|
|
133
|
+
const expiration = new AbortController();
|
|
134
|
+
const timer = setTimeout(
|
|
135
|
+
() => expiration.abort(new Error('Object storage upload lease expired during PUT')),
|
|
136
|
+
remaining
|
|
137
|
+
);
|
|
138
|
+
timer.unref();
|
|
139
|
+
const signal = options?.signal ? AbortSignal.any([options.signal, expiration.signal]) : expiration.signal;
|
|
140
|
+
let results: PromiseSettledResult<unknown>[];
|
|
141
|
+
try {
|
|
142
|
+
results = await Promise.allSettled(
|
|
143
|
+
uploads.map(async ({ upload, payload }) => {
|
|
144
|
+
signal.throwIfAborted();
|
|
145
|
+
// The timer handles time in the adapter's queue; this check also catches
|
|
146
|
+
// wall-clock changes before dispatch. Publication checks again later.
|
|
147
|
+
if (!this.canPublish(upload)) {
|
|
148
|
+
throw new Error(`Object storage upload lease expired for ${upload.path}`);
|
|
149
|
+
}
|
|
150
|
+
await this.bucketData.storeSerialized(upload.path, payload, { signal });
|
|
151
|
+
})
|
|
152
|
+
);
|
|
153
|
+
} finally {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
}
|
|
156
|
+
const failure = results.find((result) => result.status === 'rejected');
|
|
157
|
+
if (failure?.status === 'rejected') {
|
|
158
|
+
throw failure.reason;
|
|
159
|
+
}
|
|
160
|
+
signal.throwIfAborted();
|
|
161
|
+
}
|
|
162
|
+
|
|
91
163
|
/**
|
|
92
164
|
* Remove markers for files that have finished uploading, and are now referenced from other documents.
|
|
93
165
|
*
|
|
@@ -107,7 +179,9 @@ export class ObjectStorageLifecycle {
|
|
|
107
179
|
this.db.pendingObjectStorageDeletes(this.replicationStreamId),
|
|
108
180
|
{ _id: { $in: uploads.map((upload) => upload.markerId) } },
|
|
109
181
|
(deletedCount) => {
|
|
110
|
-
if (deletedCount !== uploads.length)
|
|
182
|
+
if (deletedCount !== uploads.length) {
|
|
183
|
+
throw new Error('Missing object storage publication markers');
|
|
184
|
+
}
|
|
111
185
|
}
|
|
112
186
|
);
|
|
113
187
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
|
|
2
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
3
|
+
import { updateSyncRulesFromYaml } from '@powersync/service-core';
|
|
4
|
+
import { getTestStorage, test_utils } from '@powersync/service-core-tests';
|
|
5
|
+
import { setTimeout } from 'node:timers/promises';
|
|
6
|
+
import { describe, expect, test } from 'vitest';
|
|
7
|
+
import { env } from './env.js';
|
|
8
|
+
import { TEST_STORAGE_VERSIONS } from './util.js';
|
|
9
|
+
|
|
10
|
+
describe.each(TEST_STORAGE_VERSIONS)('checkpoint cleanup v%s', (storageVersion) => {
|
|
11
|
+
test('cancels an idle cursor without waiting for another checkpoint', async (context) => {
|
|
12
|
+
const monitored = mongoTestStorageFactoryGenerator({
|
|
13
|
+
url: env.MONGO_TEST_URL,
|
|
14
|
+
isCI: env.CI,
|
|
15
|
+
monitorCommands: true
|
|
16
|
+
});
|
|
17
|
+
await using factory = await monitored.factory();
|
|
18
|
+
const stream = await factory.updateSyncRules(
|
|
19
|
+
updateSyncRulesFromYaml(
|
|
20
|
+
`
|
|
21
|
+
config:
|
|
22
|
+
edition: 3
|
|
23
|
+
streams:
|
|
24
|
+
test:
|
|
25
|
+
query: SELECT * FROM test
|
|
26
|
+
`,
|
|
27
|
+
{ storageVersion }
|
|
28
|
+
)
|
|
29
|
+
);
|
|
30
|
+
const bucketStorage = await getTestStorage(factory, stream);
|
|
31
|
+
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
32
|
+
await writer.markAllSnapshotDone('1/0');
|
|
33
|
+
await writer.keepalive('1/0');
|
|
34
|
+
|
|
35
|
+
const reading = Promise.withResolvers<void>();
|
|
36
|
+
const killed = Promise.withResolvers<void>();
|
|
37
|
+
const monitor = (event: mongo.CommandStartedEvent) => {
|
|
38
|
+
if (event.commandName === 'getMore' && event.command.collection === 'checkpoint_events') {
|
|
39
|
+
reading.resolve();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const finished = (event: mongo.CommandSucceededEvent) => {
|
|
43
|
+
if (event.commandName === 'killCursors') killed.resolve();
|
|
44
|
+
};
|
|
45
|
+
factory.db.client.on('commandStarted', monitor);
|
|
46
|
+
context.onTestFinished(() => {
|
|
47
|
+
factory.db.client.off('commandStarted', monitor);
|
|
48
|
+
});
|
|
49
|
+
factory.db.client.on('commandSucceeded', finished);
|
|
50
|
+
context.onTestFinished(() => {
|
|
51
|
+
factory.db.client.off('commandSucceeded', finished);
|
|
52
|
+
});
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
context.onTestFinished(() => controller.abort());
|
|
55
|
+
const iterator = bucketStorage
|
|
56
|
+
.watchCheckpointChanges({ user_id: 'test', signal: controller.signal })
|
|
57
|
+
[Symbol.asyncIterator]();
|
|
58
|
+
await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { base: { lsn: '1/0' } } });
|
|
59
|
+
const next = iterator.next().catch(() => {});
|
|
60
|
+
await reading.promise;
|
|
61
|
+
|
|
62
|
+
controller.abort();
|
|
63
|
+
const deadline = new AbortController();
|
|
64
|
+
try {
|
|
65
|
+
await Promise.race([
|
|
66
|
+
Promise.all([next, iterator.return?.(), killed.promise]),
|
|
67
|
+
setTimeout(2_000, undefined, { signal: deadline.signal }).then(() => {
|
|
68
|
+
throw new Error('Checkpoint cleanup waited for an idle getMore or a new checkpoint');
|
|
69
|
+
})
|
|
70
|
+
]);
|
|
71
|
+
} finally {
|
|
72
|
+
deadline.abort();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|