@powersync/service-module-mongodb-storage 0.23.2 → 0.24.1
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/LICENSE +201 -67
- package/dist/storage/MongoBucketStorage.d.ts +1 -0
- package/dist/storage/MongoBucketStorage.js +45 -83
- 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 +55 -122
- 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 +101 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
|
|
2
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
3
|
+
import { logger } from '@powersync/lib-services-framework';
|
|
4
|
+
import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
|
|
5
|
+
import { getTestStorage, test_utils } from '@powersync/service-core-tests';
|
|
6
|
+
import * as bson from 'bson';
|
|
7
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
8
|
+
import { MongoWriteBatch } from '../../src/storage/implementation/MongoWriteBatch.js';
|
|
9
|
+
import { MongoBucketBatchV3 } from '../../src/storage/implementation/v3/MongoBucketBatchV3.js';
|
|
10
|
+
import { MongoSyncBucketStorageV3 } from '../../src/storage/implementation/v3/MongoSyncBucketStorageV3.js';
|
|
11
|
+
import {
|
|
12
|
+
OBJECT_STORAGE_PUBLICATION_SAFETY_MARGIN_MS,
|
|
13
|
+
ObjectStorageLifecycle
|
|
14
|
+
} from '../../src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js';
|
|
15
|
+
import { ObjectStorageUsage } from '../../src/storage/implementation/v3/object-storage/ObjectStorageUsage.js';
|
|
16
|
+
import { PersistedBatchV3 } from '../../src/storage/implementation/v3/PersistedBatchV3.js';
|
|
17
|
+
import { env } from './env.js';
|
|
18
|
+
import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
|
|
19
|
+
import { PublicationCommandMonitor } from './helpers/PublicationCommandMonitor.js';
|
|
20
|
+
|
|
21
|
+
const CONFIG = `
|
|
22
|
+
config:
|
|
23
|
+
edition: 3
|
|
24
|
+
streams:
|
|
25
|
+
items:
|
|
26
|
+
query: SELECT * FROM items
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
async function open(version: number, options: Partial<storage.CreateWriterOptions> = {}) {
|
|
30
|
+
const objectStorage = new MemoryObjectStorage();
|
|
31
|
+
const factoryGen = mongoTestStorageFactoryGenerator({
|
|
32
|
+
url: env.MONGO_TEST_URL,
|
|
33
|
+
isCI: env.CI,
|
|
34
|
+
monitorCommands: true,
|
|
35
|
+
objectStorage,
|
|
36
|
+
inlineThresholdBytes: 0
|
|
37
|
+
});
|
|
38
|
+
const factory = await factoryGen.factory();
|
|
39
|
+
const stream = await factory.updateSyncRules(updateSyncRulesFromYaml(CONFIG, { storageVersion: version }));
|
|
40
|
+
const bucketStorage = (await getTestStorage(factory, stream)) as MongoSyncBucketStorageV3;
|
|
41
|
+
const writer = (await bucketStorage.createWriter({ ...test_utils.BATCH_OPTIONS, ...options })) as MongoBucketBatchV3;
|
|
42
|
+
const table = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
|
|
43
|
+
await writer.markAllSnapshotDone('1/1');
|
|
44
|
+
const db = bucketStorage.db;
|
|
45
|
+
const markers = db.pendingObjectStorageDeletes(stream.replicationStreamId);
|
|
46
|
+
// The generic test factory clears stream data but retains old outbox
|
|
47
|
+
// collections; reset this test's outbox as well when stream IDs are reused.
|
|
48
|
+
await markers.deleteMany({});
|
|
49
|
+
const lifecycle = new ObjectStorageLifecycle(db, stream.replicationStreamId, objectStorage);
|
|
50
|
+
const data = db.bucketData(
|
|
51
|
+
stream.replicationStreamId,
|
|
52
|
+
stream.syncConfigContent[0].mapping.allBucketDefinitionIds()[0]
|
|
53
|
+
);
|
|
54
|
+
const records = db.sourceRecords(stream.replicationStreamId, table.id as bson.ObjectId);
|
|
55
|
+
await writer.save({
|
|
56
|
+
sourceTable: table,
|
|
57
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
58
|
+
after: { id: 'A', description: 'offloaded' },
|
|
59
|
+
afterReplicaId: test_utils.rid('A')
|
|
60
|
+
});
|
|
61
|
+
return { factory, stream, bucketStorage, writer, table, db, objectStorage, markers, lifecycle, data, records };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe.each([3, 4])('replication uploads v%s', (version) => {
|
|
65
|
+
test('registers once with majority acknowledgement before PUT and reuses uploads on transaction retry', async () => {
|
|
66
|
+
const context = await open(version);
|
|
67
|
+
await using factory = context.factory;
|
|
68
|
+
await using writer = context.writer;
|
|
69
|
+
const { markers, objectStorage, db } = context;
|
|
70
|
+
using monitor = new PublicationCommandMonitor(factory.db.client);
|
|
71
|
+
using registered = vi.spyOn(ObjectStorageLifecycle.prototype, 'prepareUploads');
|
|
72
|
+
const put = objectStorage.put.bind(objectStorage);
|
|
73
|
+
using uploading = vi.spyOn(objectStorage, 'put').mockImplementation(async (...args) => {
|
|
74
|
+
expect(monitor.activeReplicationAttempts).toHaveLength(0);
|
|
75
|
+
expect(await markers.findOne({ path: args[0] })).toMatchObject({ path: args[0], delete_after: expect.any(Date) });
|
|
76
|
+
await put(...args);
|
|
77
|
+
});
|
|
78
|
+
const registrations: { majority: boolean; transactional: boolean }[] = [];
|
|
79
|
+
const command = (event: mongo.CommandStartedEvent) => {
|
|
80
|
+
if (event.commandName === 'insert' && event.command.insert === markers.collectionName) {
|
|
81
|
+
registrations.push({
|
|
82
|
+
majority: event.command.writeConcern?.w === 'majority',
|
|
83
|
+
transactional: event.command.autocommit === false
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
db.client.on('commandStarted', command);
|
|
88
|
+
using _listener = { [Symbol.dispose]: () => db.client.off('commandStarted', command) };
|
|
89
|
+
const publish = PersistedBatchV3.prototype.publish;
|
|
90
|
+
using publishing = vi.spyOn(PersistedBatchV3.prototype, 'publish').mockImplementationOnce(async function (
|
|
91
|
+
this: PersistedBatchV3,
|
|
92
|
+
...args
|
|
93
|
+
) {
|
|
94
|
+
await publish.apply(this, args);
|
|
95
|
+
throw new mongo.MongoServerError({
|
|
96
|
+
code: 112,
|
|
97
|
+
message: 'publication conflict',
|
|
98
|
+
errorLabels: ['TransientTransactionError']
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
await writer.commit('1/2');
|
|
102
|
+
expect(publishing).toHaveBeenCalledTimes(2);
|
|
103
|
+
expect(uploading).toHaveBeenCalledTimes(1);
|
|
104
|
+
expect(registered).toHaveBeenCalledTimes(1);
|
|
105
|
+
expect(registrations).toEqual([{ majority: true, transactional: false }]);
|
|
106
|
+
expect(await markers.countDocuments()).toBe(0);
|
|
107
|
+
expect(await context.data.countDocuments()).toBe(1);
|
|
108
|
+
const [usage] = await ObjectStorageUsage.readAllDefinitionUsage(db);
|
|
109
|
+
expect(usage.active_bytes).toBe(BigInt([...objectStorage.store.values()][0].data.byteLength));
|
|
110
|
+
// The first commit also activates the config in its own fenced transaction.
|
|
111
|
+
expect(monitor.replicationAttempts.map((attempt) => attempt.outcome)).toEqual([
|
|
112
|
+
'aborted',
|
|
113
|
+
'committed',
|
|
114
|
+
'committed'
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('does not PUT after ambiguous marker registration', async () => {
|
|
119
|
+
const context = await open(version);
|
|
120
|
+
await using _factory = context.factory;
|
|
121
|
+
await using writer = context.writer;
|
|
122
|
+
const prepare = ObjectStorageLifecycle.prototype.prepareUploads;
|
|
123
|
+
const failure = new Error('lost marker registration response');
|
|
124
|
+
using _registration = vi
|
|
125
|
+
.spyOn(ObjectStorageLifecycle.prototype, 'prepareUploads')
|
|
126
|
+
.mockImplementationOnce(async function (this: ObjectStorageLifecycle, ...args) {
|
|
127
|
+
await prepare.apply(this, args);
|
|
128
|
+
throw failure;
|
|
129
|
+
});
|
|
130
|
+
using put = vi.spyOn(context.objectStorage, 'put');
|
|
131
|
+
await expect(writer.commit('1/2')).rejects.toThrow(failure);
|
|
132
|
+
expect(put).not.toHaveBeenCalled();
|
|
133
|
+
expect(await context.markers.countDocuments()).toBe(1);
|
|
134
|
+
expect(await context.records.countDocuments()).toBe(0);
|
|
135
|
+
expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
|
|
136
|
+
// A later explicit retry reevaluates the retained input using a fresh path.
|
|
137
|
+
await writer.commit('1/2');
|
|
138
|
+
expect(put).toHaveBeenCalledTimes(1);
|
|
139
|
+
expect(await context.markers.countDocuments()).toBe(1);
|
|
140
|
+
expect(await context.records.countDocuments()).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('lost PUT responses retain orphan markers, and cleanup retries deletion failures', async () => {
|
|
144
|
+
const context = await open(version);
|
|
145
|
+
await using _factory = context.factory;
|
|
146
|
+
await using writer = context.writer;
|
|
147
|
+
const put = context.objectStorage.put.bind(context.objectStorage);
|
|
148
|
+
using _put = vi.spyOn(context.objectStorage, 'put').mockImplementationOnce(async (...args) => {
|
|
149
|
+
await put(...args);
|
|
150
|
+
throw new Error('lost PUT response');
|
|
151
|
+
});
|
|
152
|
+
await expect(writer.commit('1/2')).rejects.toThrow('lost PUT response');
|
|
153
|
+
expect(context.objectStorage.store.size).toBe(1);
|
|
154
|
+
expect(await context.markers.countDocuments()).toBe(1);
|
|
155
|
+
expect(await context.data.countDocuments()).toBe(0);
|
|
156
|
+
expect(await ObjectStorageUsage.readAllDefinitionUsage(context.db)).toEqual([]);
|
|
157
|
+
await context.lifecycle.cleanup(logger);
|
|
158
|
+
expect(context.objectStorage.store.size).toBe(1);
|
|
159
|
+
await context.markers.updateMany({}, { $set: { delete_after: new Date(0) } });
|
|
160
|
+
using deleting = vi.spyOn(context.objectStorage, 'delete').mockRejectedValueOnce(new Error('DELETE failed'));
|
|
161
|
+
await context.lifecycle.cleanup(logger);
|
|
162
|
+
expect(await context.markers.countDocuments()).toBe(1);
|
|
163
|
+
expect(context.objectStorage.store.size).toBe(1);
|
|
164
|
+
await context.lifecycle.cleanup(logger);
|
|
165
|
+
expect(deleting).toHaveBeenCalledTimes(2);
|
|
166
|
+
expect(await context.markers.countDocuments()).toBe(0);
|
|
167
|
+
expect(context.objectStorage.store.size).toBe(0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('rejects an uploaded generation whose assigned ID does not advance the persisted head', async () => {
|
|
171
|
+
const context = await open(version);
|
|
172
|
+
await using _factory = context.factory;
|
|
173
|
+
await using writer = context.writer;
|
|
174
|
+
const put = context.objectStorage.put.bind(context.objectStorage);
|
|
175
|
+
using uploads = vi.spyOn(context.objectStorage, 'put').mockImplementationOnce(async (...args) => {
|
|
176
|
+
await put(...args);
|
|
177
|
+
await context.db.sync_rules.updateOne(
|
|
178
|
+
{ _id: context.stream.replicationStreamId },
|
|
179
|
+
{ $set: { last_persisted_op: 10n } },
|
|
180
|
+
{ writeConcern: { w: 'majority' } }
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
await expect(writer.commit('1/2')).rejects.toThrow('does not advance persisted stream head');
|
|
184
|
+
expect(uploads).toHaveBeenCalledTimes(1);
|
|
185
|
+
const [abandoned] = uploads.mock.calls.map(([path]) => path);
|
|
186
|
+
expect(await context.markers.find({}).toArray()).toMatchObject([{ path: abandoned }]);
|
|
187
|
+
expect(await context.data.find({}).toArray()).toEqual([]);
|
|
188
|
+
expect(writer.last_flushed_op).toBeNull();
|
|
189
|
+
expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
|
|
190
|
+
expect(await context.db.sync_rules.findOne({ _id: context.stream.replicationStreamId })).toMatchObject({
|
|
191
|
+
last_persisted_op: 10n
|
|
192
|
+
});
|
|
193
|
+
await context.markers.updateMany({}, { $set: { delete_after: new Date(0) } });
|
|
194
|
+
await context.lifecycle.cleanup(logger);
|
|
195
|
+
expect(context.objectStorage.store.size).toBe(0);
|
|
196
|
+
expect(await context.markers.countDocuments()).toBe(0);
|
|
197
|
+
expect(await ObjectStorageUsage.readAllDefinitionUsage(context.db)).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('stopped-stream termination clears abandoned replication objects and their markers', async () => {
|
|
201
|
+
const context = await open(version);
|
|
202
|
+
await using factory = context.factory;
|
|
203
|
+
await using writer = context.writer;
|
|
204
|
+
const put = context.objectStorage.put.bind(context.objectStorage);
|
|
205
|
+
using _put = vi.spyOn(context.objectStorage, 'put').mockImplementationOnce(async (...args) => {
|
|
206
|
+
await put(...args);
|
|
207
|
+
throw new Error('lost PUT response');
|
|
208
|
+
});
|
|
209
|
+
await expect(writer.flush()).rejects.toThrow('lost PUT response');
|
|
210
|
+
expect(context.objectStorage.store.size).toBe(1);
|
|
211
|
+
await factory.db.sync_rules.updateOne(
|
|
212
|
+
{ _id: context.stream.replicationStreamId },
|
|
213
|
+
{ $set: { state: storage.SyncRuleState.STOP, 'sync_configs.$[].state': storage.SyncRuleState.STOP } }
|
|
214
|
+
);
|
|
215
|
+
// Supported termination runs after the replication job has stopped/drained
|
|
216
|
+
// and released its lease. It removes the entire prefix, including orphans.
|
|
217
|
+
await test_utils.releaseTestStorageLease(factory, context.stream.replicationStreamId);
|
|
218
|
+
await context.bucketStorage.terminate();
|
|
219
|
+
expect(context.objectStorage.store.size).toBe(0);
|
|
220
|
+
expect(await context.markers.countDocuments()).toBe(0);
|
|
221
|
+
expect(await factory.db.sync_rules.findOne({ _id: context.stream.replicationStreamId })).toMatchObject({
|
|
222
|
+
state: storage.SyncRuleState.TERMINATED
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test.each([false, true])(
|
|
227
|
+
'missing markers roll back references, usage and progress and stop the lease (fallback: %s)',
|
|
228
|
+
async (fallback) => {
|
|
229
|
+
const context = await open(version);
|
|
230
|
+
await using factory = context.factory;
|
|
231
|
+
await using writer = context.writer;
|
|
232
|
+
const createWriteBatch = factory.db.createWriteBatch.bind(factory.db);
|
|
233
|
+
using _bulk = vi
|
|
234
|
+
.spyOn(factory.db, 'createWriteBatch')
|
|
235
|
+
.mockImplementation((session, options) =>
|
|
236
|
+
fallback
|
|
237
|
+
? new MongoWriteBatch(factory.db.client, false, session, options)
|
|
238
|
+
: createWriteBatch(session, options)
|
|
239
|
+
);
|
|
240
|
+
const put = context.objectStorage.put.bind(context.objectStorage);
|
|
241
|
+
using _put = vi.spyOn(context.objectStorage, 'put').mockImplementationOnce(async (...args) => {
|
|
242
|
+
await put(...args);
|
|
243
|
+
await context.markers.deleteMany({});
|
|
244
|
+
});
|
|
245
|
+
await expect(writer.commit('1/2')).rejects.toThrow('Missing object storage publication markers');
|
|
246
|
+
expect(await context.data.countDocuments()).toBe(0);
|
|
247
|
+
expect(await context.records.countDocuments()).toBe(0);
|
|
248
|
+
expect(await ObjectStorageUsage.readAllDefinitionUsage(context.db)).toEqual([]);
|
|
249
|
+
expect(writer.last_flushed_op).toBeNull();
|
|
250
|
+
expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
|
|
251
|
+
await expect(writer.commit('1/2')).rejects.toThrow('restart replication from persisted progress');
|
|
252
|
+
expect(await context.data.countDocuments()).toBe(0);
|
|
253
|
+
}
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
test('rechecks expiry on a retry and leaves the generation for cleanup', async () => {
|
|
257
|
+
const context = await open(version);
|
|
258
|
+
await using _factory = context.factory;
|
|
259
|
+
await using writer = context.writer;
|
|
260
|
+
using registered = vi.spyOn(ObjectStorageLifecycle.prototype, 'prepareUploads');
|
|
261
|
+
using put = vi.spyOn(context.objectStorage, 'put');
|
|
262
|
+
const publish = PersistedBatchV3.prototype.publish;
|
|
263
|
+
let attempts = 0;
|
|
264
|
+
using _publish = vi.spyOn(PersistedBatchV3.prototype, 'publish').mockImplementation(async function (
|
|
265
|
+
this: PersistedBatchV3,
|
|
266
|
+
...args
|
|
267
|
+
) {
|
|
268
|
+
attempts++;
|
|
269
|
+
if (attempts === 2) {
|
|
270
|
+
const uploads = await registered.mock.results[0].value;
|
|
271
|
+
uploads[0].deleteAfter = new Date(Date.now() + OBJECT_STORAGE_PUBLICATION_SAFETY_MARGIN_MS);
|
|
272
|
+
}
|
|
273
|
+
await publish.apply(this, args);
|
|
274
|
+
throw new mongo.MongoServerError({
|
|
275
|
+
code: 112,
|
|
276
|
+
message: 'publication conflict',
|
|
277
|
+
errorLabels: ['TransientTransactionError']
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
await expect(writer.commit('1/2')).rejects.toThrow('publication lease expired');
|
|
281
|
+
expect(attempts).toBe(2);
|
|
282
|
+
expect(put).toHaveBeenCalledTimes(1);
|
|
283
|
+
expect(registered).toHaveBeenCalledTimes(1);
|
|
284
|
+
expect(await context.markers.countDocuments()).toBe(1);
|
|
285
|
+
expect(await context.data.countDocuments()).toBe(0);
|
|
286
|
+
expect(await ObjectStorageUsage.readAllDefinitionUsage(context.db)).toEqual([]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test.each([false, true])(
|
|
290
|
+
'terminal commit uncertainty preserves the correct marker state (committed: %s)',
|
|
291
|
+
async (committed) => {
|
|
292
|
+
const context = await open(version);
|
|
293
|
+
await using _factory = context.factory;
|
|
294
|
+
await using writer = context.writer;
|
|
295
|
+
await using other = await context.bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
296
|
+
const failure = new mongo.MongoServerError({
|
|
297
|
+
message: 'commit outcome unknown',
|
|
298
|
+
errorLabels: ['UnknownTransactionCommitResult']
|
|
299
|
+
});
|
|
300
|
+
const session = writer.session;
|
|
301
|
+
const transact = session.withTransaction.bind(session);
|
|
302
|
+
using _transaction = vi.spyOn(session, 'withTransaction').mockImplementationOnce(async (...args) => {
|
|
303
|
+
if (committed) {
|
|
304
|
+
await transact(...args);
|
|
305
|
+
throw failure;
|
|
306
|
+
}
|
|
307
|
+
return transact(async (...callbackArgs) => {
|
|
308
|
+
await args[0](...callbackArgs);
|
|
309
|
+
throw failure;
|
|
310
|
+
}, args[1]);
|
|
311
|
+
});
|
|
312
|
+
await expect(writer.commit('1/2')).rejects.toThrow(failure);
|
|
313
|
+
expect(await context.markers.countDocuments()).toBe(committed ? 0 : 1);
|
|
314
|
+
expect(await context.data.countDocuments()).toBe(committed ? 1 : 0);
|
|
315
|
+
expect(writer.last_flushed_op).toBeNull();
|
|
316
|
+
await expect(other.setResumeLsn('1/3')).rejects.toThrow('restart replication');
|
|
317
|
+
await context.markers.updateMany({}, { $set: { delete_after: new Date(0) } });
|
|
318
|
+
await context.lifecycle.cleanup(logger);
|
|
319
|
+
expect(context.objectStorage.store.size).toBe(committed ? 1 : 0);
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
test('truncate PUTs complete before its transaction and publish their markers atomically', async () => {
|
|
324
|
+
const context = await open(version);
|
|
325
|
+
await using factory = context.factory;
|
|
326
|
+
await using writer = context.writer;
|
|
327
|
+
await writer.commit('1/2');
|
|
328
|
+
using monitor = new PublicationCommandMonitor(factory.db.client);
|
|
329
|
+
const put = context.objectStorage.put.bind(context.objectStorage);
|
|
330
|
+
using uploading = vi.spyOn(context.objectStorage, 'put').mockImplementation(async (...args) => {
|
|
331
|
+
expect(monitor.activeReplicationAttempts).toHaveLength(0);
|
|
332
|
+
expect(await context.markers.countDocuments({ path: args[0] })).toBe(1);
|
|
333
|
+
await put(...args);
|
|
334
|
+
});
|
|
335
|
+
await writer.truncate([context.table]);
|
|
336
|
+
expect(uploading).toHaveBeenCalledTimes(1);
|
|
337
|
+
expect(await context.markers.countDocuments()).toBe(0);
|
|
338
|
+
expect(await context.records.countDocuments()).toBe(0);
|
|
339
|
+
expect(await context.data.countDocuments()).toBe(2);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('cancels queued PUTs and drains active requests before returning', async () => {
|
|
343
|
+
const controller = new AbortController();
|
|
344
|
+
const context = await open(version, { signal: controller.signal });
|
|
345
|
+
await using _factory = context.factory;
|
|
346
|
+
await using writer = context.writer;
|
|
347
|
+
// Enough data for separate chunks, within a single bounded publication.
|
|
348
|
+
await writer.save({
|
|
349
|
+
sourceTable: context.table,
|
|
350
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
351
|
+
after: { id: 'B', description: 'x'.repeat(1_100_000) },
|
|
352
|
+
afterReplicaId: test_utils.rid('B')
|
|
353
|
+
});
|
|
354
|
+
const active = Promise.withResolvers<void>();
|
|
355
|
+
const release = Promise.withResolvers<void>();
|
|
356
|
+
const queued = Promise.withResolvers<void>();
|
|
357
|
+
let remoteCompletions = 0;
|
|
358
|
+
using put = vi
|
|
359
|
+
.spyOn(context.objectStorage, 'put')
|
|
360
|
+
.mockImplementationOnce(async (path, data, metadata) => {
|
|
361
|
+
active.resolve();
|
|
362
|
+
await release.promise;
|
|
363
|
+
// An accepted request can complete after local cancellation. Its marker
|
|
364
|
+
// remains protected for an hour; cancellation must not delete it early.
|
|
365
|
+
context.objectStorage.store.set(path, { data, metadata });
|
|
366
|
+
remoteCompletions++;
|
|
367
|
+
})
|
|
368
|
+
.mockImplementationOnce(async (_path, _data, _metadata, options) => {
|
|
369
|
+
queued.resolve();
|
|
370
|
+
await active.promise;
|
|
371
|
+
if (!options?.signal?.aborted) {
|
|
372
|
+
await new Promise<void>((resolve) =>
|
|
373
|
+
options!.signal!.addEventListener('abort', () => resolve(), { once: true })
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
options?.signal?.throwIfAborted();
|
|
377
|
+
});
|
|
378
|
+
const failed = expect(writer.commit('1/2')).rejects.toThrow('cancel uploads');
|
|
379
|
+
await queued.promise;
|
|
380
|
+
controller.abort(new Error('cancel uploads'));
|
|
381
|
+
expect(remoteCompletions).toBe(0);
|
|
382
|
+
release.resolve();
|
|
383
|
+
await failed;
|
|
384
|
+
expect(remoteCompletions).toBe(1);
|
|
385
|
+
expect(put).toHaveBeenCalledTimes(2);
|
|
386
|
+
expect(await context.markers.countDocuments()).toBe(2);
|
|
387
|
+
await context.markers.updateMany({}, { $set: { delete_after: new Date(0) } });
|
|
388
|
+
await context.lifecycle.cleanup(logger);
|
|
389
|
+
expect(context.objectStorage.store.size).toBe(0);
|
|
390
|
+
expect(await context.markers.countDocuments()).toBe(0);
|
|
391
|
+
expect(await context.records.countDocuments()).toBe(0);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
|
|
2
2
|
import { updateSyncRulesFromConfig } from '@powersync/service-core';
|
|
3
3
|
import { test_utils } from '@powersync/service-core-tests';
|
|
4
|
-
import { PrecompiledSyncConfig,
|
|
4
|
+
import { PrecompiledSyncConfig, parseSyncConfig } from '@powersync/service-sync-rules';
|
|
5
5
|
import { describe, expect, test } from 'vitest';
|
|
6
6
|
import { env } from './env.js';
|
|
7
7
|
import { TEST_STORAGE_VERSIONS } from './util.js';
|
|
@@ -39,7 +39,7 @@ describe.for(TEST_STORAGE_VERSIONS)('source-table config with storage v%i', (sto
|
|
|
39
39
|
let expected;
|
|
40
40
|
{
|
|
41
41
|
await using factory = await storageFactory().factory();
|
|
42
|
-
const parsed =
|
|
42
|
+
const parsed = parseSyncConfig(CONFIGURED, test_utils.PARSE_OPTIONS);
|
|
43
43
|
expected = (parsed.config as PrecompiledSyncConfig).plan.sourceTableConfig;
|
|
44
44
|
await factory.updateSyncRules(updateSyncRulesFromConfig(parsed, { storageVersion }));
|
|
45
45
|
}
|
|
@@ -56,7 +56,7 @@ describe.for(TEST_STORAGE_VERSIONS)('source-table config with storage v%i', (sto
|
|
|
56
56
|
test('continues loading unfiltered plans without source options', async () => {
|
|
57
57
|
await using factory = await storageFactory().factory();
|
|
58
58
|
await factory.updateSyncRules(
|
|
59
|
-
updateSyncRulesFromConfig(
|
|
59
|
+
updateSyncRulesFromConfig(parseSyncConfig(STREAMS, test_utils.PARSE_OPTIONS), {
|
|
60
60
|
storageVersion
|
|
61
61
|
})
|
|
62
62
|
);
|
|
@@ -69,7 +69,7 @@ describe.for(TEST_STORAGE_VERSIONS)('source-table config with storage v%i', (sto
|
|
|
69
69
|
await using factory = await storageFactory().factory();
|
|
70
70
|
const deploy = (yaml: string) =>
|
|
71
71
|
factory.updateSyncRules(
|
|
72
|
-
updateSyncRulesFromConfig(
|
|
72
|
+
updateSyncRulesFromConfig(parseSyncConfig(yaml, test_utils.PARSE_OPTIONS), {
|
|
73
73
|
storageVersion
|
|
74
74
|
})
|
|
75
75
|
);
|
|
@@ -2,8 +2,8 @@ import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
|
|
|
2
2
|
import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
|
|
3
3
|
import { bucketRequest, getTestStorage, test_utils } from '@powersync/service-core-tests';
|
|
4
4
|
import { describe, expect, test } from 'vitest';
|
|
5
|
-
import { DEFAULT_INLINE_THRESHOLD_BYTES } from '../../src/storage/implementation/common/PersistedBatch.js';
|
|
6
5
|
import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
|
|
6
|
+
import { DEFAULT_INLINE_THRESHOLD_BYTES } from '../../src/storage/implementation/v3/PersistedBatchV3.js';
|
|
7
7
|
import { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
|
|
8
8
|
import { env } from './env.js';
|
|
9
9
|
import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as lib_mongo from '@powersync/lib-service-mongodb';
|
|
2
2
|
import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
|
|
3
3
|
import { bucketRequest, compactActive, getTestStorage, test_utils } from '@powersync/service-core-tests';
|
|
4
|
-
import { describe, expect, test } from 'vitest';
|
|
4
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
5
5
|
import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
|
|
6
6
|
import { MongoSyncBucketStorageV3 } from '../../src/storage/implementation/v3/MongoSyncBucketStorageV3.js';
|
|
7
7
|
import { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
|
|
@@ -53,6 +53,98 @@ async function expectUsageMatchesBucketData(bucketStorage: MongoSyncBucketStorag
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
describe('S3 compaction storage lifecycle', () => {
|
|
56
|
+
test.each(
|
|
57
|
+
[false, true].flatMap((compactChunksOnly) =>
|
|
58
|
+
[
|
|
59
|
+
...[-1, 0, 1].map((thresholdOffset) => ({ thresholdOffset, initiallyInline: true })),
|
|
60
|
+
{ thresholdOffset: -1, initiallyInline: false }
|
|
61
|
+
].map((placement) => ({ compactChunksOnly, ...placement }))
|
|
62
|
+
)
|
|
63
|
+
)(
|
|
64
|
+
'unchanged chunk placement: V4, chunks only=$compactChunksOnly, inline=$initiallyInline, threshold offset=$thresholdOffset',
|
|
65
|
+
async ({ compactChunksOnly, thresholdOffset, initiallyInline }) => {
|
|
66
|
+
const { memoryStorage, factory: factoryGen } = memoryS3Factory({
|
|
67
|
+
inlineThresholdBytes: initiallyInline ? 10_000 : 0
|
|
68
|
+
});
|
|
69
|
+
await using factory = await factoryGen.factory();
|
|
70
|
+
const syncRules = await factory.updateSyncRules(
|
|
71
|
+
updateSyncRulesFromYaml(
|
|
72
|
+
`
|
|
73
|
+
config:
|
|
74
|
+
edition: 3
|
|
75
|
+
streams:
|
|
76
|
+
global:
|
|
77
|
+
query: SELECT id, description FROM items
|
|
78
|
+
`,
|
|
79
|
+
{ storageVersion: 4 }
|
|
80
|
+
)
|
|
81
|
+
);
|
|
82
|
+
const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorageV3;
|
|
83
|
+
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
84
|
+
const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
|
|
85
|
+
await writer.markAllSnapshotDone('1/1');
|
|
86
|
+
await writer.save({
|
|
87
|
+
sourceTable,
|
|
88
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
89
|
+
after: { id: 'A', description: 'unchanged' },
|
|
90
|
+
afterReplicaId: test_utils.rid('A')
|
|
91
|
+
});
|
|
92
|
+
await writer.commit('1/1');
|
|
93
|
+
|
|
94
|
+
const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
|
|
95
|
+
const db = bucketStorage.db;
|
|
96
|
+
const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
|
|
97
|
+
const [before] = await collection.find({}).toArray();
|
|
98
|
+
expect(before.ops != null).toBe(initiallyInline);
|
|
99
|
+
expect(before.storage_ref != null).toBe(!initiallyInline);
|
|
100
|
+
const checkpoint = await bucketStorage.getCheckpoint();
|
|
101
|
+
const request = bucketRequest(syncRules.syncConfigContent[0], 'global|0[]', 0n);
|
|
102
|
+
const dataBefore = await test_utils.getBatchArray(bucketStorage.getBucketDataBatch(checkpoint, [request]));
|
|
103
|
+
|
|
104
|
+
// Simulate changing the threshold after this chunk was written.
|
|
105
|
+
const reconfiguredStorage = new MongoSyncBucketStorageV3(
|
|
106
|
+
factory,
|
|
107
|
+
bucketStorage.replicationStreamId,
|
|
108
|
+
bucketStorage.replicationStream,
|
|
109
|
+
bucketStorage.replicationStreamName,
|
|
110
|
+
undefined,
|
|
111
|
+
{
|
|
112
|
+
storageConfig: bucketStorage.storageConfig,
|
|
113
|
+
objectStorage: memoryStorage,
|
|
114
|
+
inlineThresholdBytes: before.size + thresholdOffset
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
const put = vi.spyOn(memoryStorage, 'put');
|
|
118
|
+
expect(
|
|
119
|
+
await reconfiguredStorage
|
|
120
|
+
.createMongoCompactor({
|
|
121
|
+
maxOpId: checkpoint.checkpoint,
|
|
122
|
+
...(compactChunksOnly
|
|
123
|
+
? { compactChunksOnly: true, forceChunkCompaction: true }
|
|
124
|
+
: { compactBuckets: [request.bucket] })
|
|
125
|
+
})
|
|
126
|
+
.compact()
|
|
127
|
+
).toBe(1);
|
|
128
|
+
|
|
129
|
+
const documents = await collection.find({}).toArray();
|
|
130
|
+
expect(documents).toHaveLength(1);
|
|
131
|
+
const [after] = documents;
|
|
132
|
+
if (initiallyInline && thresholdOffset < 0) {
|
|
133
|
+
expect(after.storage_ref).toBeDefined();
|
|
134
|
+
expect(after.ops).toBeUndefined();
|
|
135
|
+
expect(put).toHaveBeenCalledTimes(1);
|
|
136
|
+
const { ops, ...metadata } = before;
|
|
137
|
+
expect(after).toMatchObject(metadata);
|
|
138
|
+
} else {
|
|
139
|
+
expect(after).toEqual(before);
|
|
140
|
+
expect(put).not.toHaveBeenCalled();
|
|
141
|
+
}
|
|
142
|
+
await expectUsageMatchesBucketData(reconfiguredStorage, definitionId);
|
|
143
|
+
const dataAfter = await test_utils.getBatchArray(reconfiguredStorage.getBucketDataBatch(checkpoint, [request]));
|
|
144
|
+
expect(test_utils.getBatchData(dataAfter)).toEqual(test_utils.getBatchData(dataBefore));
|
|
145
|
+
}
|
|
146
|
+
);
|
|
147
|
+
|
|
56
148
|
test('a later compaction recovers from a transient object storage failure', async () => {
|
|
57
149
|
const { memoryStorage, factory: factoryGen } = memoryS3Factory();
|
|
58
150
|
await using factory = await factoryGen.factory();
|