@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,388 @@
|
|
|
1
|
+
import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
|
|
2
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
3
|
+
import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
|
|
4
|
+
import { getTestStorage, test_utils } from '@powersync/service-core-tests';
|
|
5
|
+
import * as bson from 'bson';
|
|
6
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
7
|
+
import { PreparedPublication } from '../../src/storage/implementation/common/PreparedPublication.js';
|
|
8
|
+
import { MongoWriteBatch } from '../../src/storage/implementation/MongoWriteBatch.js';
|
|
9
|
+
import { MongoSyncBucketStorageV3 } from '../../src/storage/implementation/v3/MongoSyncBucketStorageV3.js';
|
|
10
|
+
import { ObjectStorageUsage } from '../../src/storage/implementation/v3/object-storage/ObjectStorageUsage.js';
|
|
11
|
+
import { PersistedBatchV3 } from '../../src/storage/implementation/v3/PersistedBatchV3.js';
|
|
12
|
+
import { env } from './env.js';
|
|
13
|
+
import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
|
|
14
|
+
|
|
15
|
+
const SYNC_CONFIG = `
|
|
16
|
+
config:
|
|
17
|
+
edition: 3
|
|
18
|
+
streams:
|
|
19
|
+
items:
|
|
20
|
+
query: SELECT * FROM items
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
async function openStream(
|
|
24
|
+
storageVersion: number,
|
|
25
|
+
objectStorage: MemoryObjectStorage | undefined,
|
|
26
|
+
inlineThresholdBytes = 0,
|
|
27
|
+
syncConfig = SYNC_CONFIG
|
|
28
|
+
) {
|
|
29
|
+
const factoryGen = mongoTestStorageFactoryGenerator({
|
|
30
|
+
url: env.MONGO_TEST_URL,
|
|
31
|
+
isCI: env.CI,
|
|
32
|
+
objectStorage,
|
|
33
|
+
inlineThresholdBytes
|
|
34
|
+
});
|
|
35
|
+
const factory = await factoryGen.factory();
|
|
36
|
+
const stream = await factory.updateSyncRules(updateSyncRulesFromYaml(syncConfig, { storageVersion }));
|
|
37
|
+
const bucketStorage = (await getTestStorage(factory, stream)) as MongoSyncBucketStorageV3;
|
|
38
|
+
const writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
39
|
+
const table = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
|
|
40
|
+
await writer.markAllSnapshotDone('1/1');
|
|
41
|
+
return { factory, factoryGen, stream, bucketStorage, writer, table };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe.each([3, 4])('sealed publication v%s', (storageVersion) => {
|
|
45
|
+
test.each([
|
|
46
|
+
{ enabled: false, offloaded: false, fallback: false },
|
|
47
|
+
{ enabled: false, offloaded: false, fallback: true },
|
|
48
|
+
{ enabled: true, offloaded: false, fallback: false },
|
|
49
|
+
{ enabled: true, offloaded: true, fallback: false },
|
|
50
|
+
{ enabled: true, offloaded: false, fallback: true },
|
|
51
|
+
{ enabled: true, offloaded: true, fallback: true }
|
|
52
|
+
])('replays writes after rollback without changing payloads: %j', async ({ enabled, offloaded, fallback }) => {
|
|
53
|
+
const objectStorage = new MemoryObjectStorage();
|
|
54
|
+
const context = await openStream(storageVersion, enabled ? objectStorage : undefined, offloaded ? 0 : 16 * 1024);
|
|
55
|
+
await using factory = context.factory;
|
|
56
|
+
await using writer = context.writer;
|
|
57
|
+
const { table, bucketStorage, stream } = context;
|
|
58
|
+
const db = bucketStorage.db;
|
|
59
|
+
const createWriteBatch = factory.db.createWriteBatch.bind(factory.db);
|
|
60
|
+
using _bulkMode = vi
|
|
61
|
+
.spyOn(factory.db, 'createWriteBatch')
|
|
62
|
+
.mockImplementation((session, options) =>
|
|
63
|
+
fallback ? new MongoWriteBatch(factory.db.client, false, session, options) : createWriteBatch(session, options)
|
|
64
|
+
);
|
|
65
|
+
using put = vi.spyOn(objectStorage, 'put');
|
|
66
|
+
let publication: PreparedPublication | undefined;
|
|
67
|
+
let builder: PersistedBatchV3 | undefined;
|
|
68
|
+
const aborted = new Error('abort after data writes');
|
|
69
|
+
const originalPublish = PersistedBatchV3.prototype.publish;
|
|
70
|
+
using _flush = vi.spyOn(PersistedBatchV3.prototype, 'publish').mockImplementationOnce(async function (
|
|
71
|
+
this: PersistedBatchV3,
|
|
72
|
+
...args
|
|
73
|
+
) {
|
|
74
|
+
builder = this;
|
|
75
|
+
publication = this;
|
|
76
|
+
const ready = publication.preparePayloads();
|
|
77
|
+
expect(this.preparePayloads()).toBe(ready);
|
|
78
|
+
await ready;
|
|
79
|
+
await originalPublish.apply(this, args);
|
|
80
|
+
throw aborted;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Repeated records, partial updates, a source ID change and deletion must
|
|
84
|
+
// retain their history and final membership when a sealed batch is replayed.
|
|
85
|
+
await writer.save({
|
|
86
|
+
sourceTable: table,
|
|
87
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
88
|
+
after: { id: 'A', description: 'first' },
|
|
89
|
+
afterReplicaId: test_utils.rid('A')
|
|
90
|
+
});
|
|
91
|
+
await writer.save({
|
|
92
|
+
sourceTable: table,
|
|
93
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
94
|
+
after: { id: undefined, description: 'second' },
|
|
95
|
+
beforeReplicaId: test_utils.rid('A'),
|
|
96
|
+
afterReplicaId: test_utils.rid('A')
|
|
97
|
+
});
|
|
98
|
+
await writer.save({
|
|
99
|
+
sourceTable: table,
|
|
100
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
101
|
+
after: { id: 'B', description: undefined },
|
|
102
|
+
beforeReplicaId: test_utils.rid('A'),
|
|
103
|
+
afterReplicaId: test_utils.rid('B')
|
|
104
|
+
});
|
|
105
|
+
await writer.save({
|
|
106
|
+
sourceTable: table,
|
|
107
|
+
tag: storage.SaveOperationTag.DELETE,
|
|
108
|
+
beforeReplicaId: test_utils.rid('B')
|
|
109
|
+
});
|
|
110
|
+
await writer.save({
|
|
111
|
+
sourceTable: table,
|
|
112
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
113
|
+
after: { id: 'C', description: 'final' },
|
|
114
|
+
afterReplicaId: test_utils.rid('C')
|
|
115
|
+
});
|
|
116
|
+
await expect(writer.commit('1/2')).rejects.toThrow(aborted);
|
|
117
|
+
expect(publication).toBeDefined();
|
|
118
|
+
expect(publication).toBe(builder);
|
|
119
|
+
expect(writer.last_flushed_op).toBeNull();
|
|
120
|
+
expect((await bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
|
|
121
|
+
const definitionId = stream.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
|
|
122
|
+
const data = db.bucketData(stream.replicationStreamId, definitionId);
|
|
123
|
+
expect(await data.countDocuments()).toBe(0);
|
|
124
|
+
expect(await db.sourceRecords(stream.replicationStreamId, table.id as bson.ObjectId).countDocuments()).toBe(0);
|
|
125
|
+
expect(await ObjectStorageUsage.readAllDefinitionUsage(db)).toEqual([]);
|
|
126
|
+
const markers = db.pendingObjectStorageDeletes(stream.replicationStreamId);
|
|
127
|
+
const markerFilter = { path: { $in: [...objectStorage.store.keys()] } };
|
|
128
|
+
expect(await markers.countDocuments(markerFilter)).toBe(offloaded ? 1 : 0);
|
|
129
|
+
expect(put).toHaveBeenCalledTimes(offloaded ? 1 : 0);
|
|
130
|
+
const paths = [...objectStorage.store.keys()];
|
|
131
|
+
const bytes = paths.map((path) => Buffer.from(objectStorage.store.get(path)!.data));
|
|
132
|
+
|
|
133
|
+
// Mutators must not reclaim ownership or clear sealed writes.
|
|
134
|
+
expect(() => builder!.seal()).toThrow('sealed');
|
|
135
|
+
expect(() => builder!.hardDeleteCurrentData(table.id as bson.ObjectId, test_utils.rid('C'))).toThrow('sealed');
|
|
136
|
+
await db.client.withSession(async (session) => {
|
|
137
|
+
let attempts = 0;
|
|
138
|
+
await publication!.preparePayloads();
|
|
139
|
+
await session.withTransaction(async () => {
|
|
140
|
+
expect(await publication!.publish(session)).toEqual({
|
|
141
|
+
bucketDataCount: 6,
|
|
142
|
+
parameterDataCount: 0,
|
|
143
|
+
currentDataCount: 3,
|
|
144
|
+
flushedAny: true
|
|
145
|
+
});
|
|
146
|
+
attempts += 1;
|
|
147
|
+
if (attempts === 1) {
|
|
148
|
+
throw new mongo.MongoServerError({
|
|
149
|
+
message: 'retry sealed writes',
|
|
150
|
+
code: 112,
|
|
151
|
+
errorLabels: ['TransientTransactionError']
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
expect(attempts).toBe(2);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(put).toHaveBeenCalledTimes(offloaded ? 1 : 0);
|
|
159
|
+
expect([...objectStorage.store.keys()]).toEqual(paths);
|
|
160
|
+
expect(await markers.countDocuments(markerFilter)).toBe(0);
|
|
161
|
+
expect(paths.map((path) => Buffer.from(objectStorage.store.get(path)!.data))).toEqual(bytes);
|
|
162
|
+
const documents = await data.find({}).toArray();
|
|
163
|
+
expect(documents).toHaveLength(1);
|
|
164
|
+
const operations = offloaded
|
|
165
|
+
? bson.deserialize(
|
|
166
|
+
objectStorage.store.get(documents[0].storage_ref!.path)!.data,
|
|
167
|
+
storage.BSON_DESERIALIZE_INTERNAL_OPTIONS
|
|
168
|
+
).ops
|
|
169
|
+
: documents[0].ops;
|
|
170
|
+
expect(operations).toMatchObject([
|
|
171
|
+
{ o: 1n, op: 'PUT', row_id: 'A', data: '{"id":"A","description":"first"}' },
|
|
172
|
+
{ o: 2n, op: 'PUT', row_id: 'A', data: '{"id":"A","description":"second"}' },
|
|
173
|
+
{ o: 3n, op: 'REMOVE', row_id: 'A' },
|
|
174
|
+
{ o: 4n, op: 'PUT', row_id: 'B', data: '{"id":"B","description":"second"}' },
|
|
175
|
+
{ o: 6n, op: 'REMOVE', row_id: 'B' },
|
|
176
|
+
{ o: 8n, op: 'PUT', row_id: 'C', data: '{"id":"C","description":"final"}' }
|
|
177
|
+
]);
|
|
178
|
+
expect(await db.bucketState(stream.replicationStreamId).findOne({ '_id.d': definitionId })).toMatchObject({
|
|
179
|
+
last_op: 8n,
|
|
180
|
+
bucket_stats: { count: 6, chunks: 1, bytes: BigInt(documents[0].size) }
|
|
181
|
+
});
|
|
182
|
+
const records = await db
|
|
183
|
+
.sourceRecords(stream.replicationStreamId, table.id as bson.ObjectId)
|
|
184
|
+
.find({})
|
|
185
|
+
.toArray();
|
|
186
|
+
expect(records).toHaveLength(3);
|
|
187
|
+
for (const [id, op] of [
|
|
188
|
+
['A', 5n],
|
|
189
|
+
['B', 7n]
|
|
190
|
+
] as const) {
|
|
191
|
+
expect(records.find((record) => record.pending_delete === op)).toMatchObject({
|
|
192
|
+
_id: test_utils.rid(id),
|
|
193
|
+
data: null,
|
|
194
|
+
buckets: [],
|
|
195
|
+
lookups: [],
|
|
196
|
+
pending_delete: op
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const finalRecord = records.find((record) => record.data != null);
|
|
200
|
+
expect(finalRecord!._id).toEqual(test_utils.rid('C'));
|
|
201
|
+
expect(bson.deserialize(finalRecord!.data!.buffer)).toEqual({ id: 'C', description: 'final' });
|
|
202
|
+
expect(finalRecord!.buckets).toHaveLength(1);
|
|
203
|
+
const usage = await ObjectStorageUsage.readAllDefinitionUsage(db);
|
|
204
|
+
expect(usage).toEqual(
|
|
205
|
+
offloaded
|
|
206
|
+
? [
|
|
207
|
+
{
|
|
208
|
+
replication_stream_id: stream.replicationStreamId,
|
|
209
|
+
definition_id: definitionId,
|
|
210
|
+
active_bytes: BigInt(bytes[0].byteLength)
|
|
211
|
+
}
|
|
212
|
+
]
|
|
213
|
+
: []
|
|
214
|
+
);
|
|
215
|
+
// Publishing a payload alone never advances the scheduling envelope.
|
|
216
|
+
expect((await bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
|
|
217
|
+
expect(writer.last_flushed_op).toBeNull();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test.each(['disabled', 'inline', 'offloaded'])(
|
|
221
|
+
'writer uses sealed publications with %s payload storage',
|
|
222
|
+
async (mode) => {
|
|
223
|
+
const objectStorage = new MemoryObjectStorage();
|
|
224
|
+
const context = await openStream(
|
|
225
|
+
storageVersion,
|
|
226
|
+
mode === 'disabled' ? undefined : objectStorage,
|
|
227
|
+
mode === 'inline' ? 16 * 1024 : 0
|
|
228
|
+
);
|
|
229
|
+
await using _factory = context.factory;
|
|
230
|
+
await using writer = context.writer;
|
|
231
|
+
using seal = vi.spyOn(PersistedBatchV3.prototype, 'seal');
|
|
232
|
+
using publish = vi.spyOn(PersistedBatchV3.prototype, 'publish');
|
|
233
|
+
using put = vi.spyOn(objectStorage, 'put');
|
|
234
|
+
await writer.save({
|
|
235
|
+
sourceTable: context.table,
|
|
236
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
237
|
+
after: { id: 'A', description: 'inline or offloaded' },
|
|
238
|
+
afterReplicaId: test_utils.rid('A')
|
|
239
|
+
});
|
|
240
|
+
await writer.commit('1/2');
|
|
241
|
+
expect(seal).toHaveBeenCalledTimes(1);
|
|
242
|
+
expect(publish).toHaveBeenCalledTimes(1);
|
|
243
|
+
expect(put).toHaveBeenCalledTimes(mode === 'offloaded' ? 1 : 0);
|
|
244
|
+
expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(1n);
|
|
245
|
+
const definitionId = context.stream.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
|
|
246
|
+
const documents = await context.bucketStorage.db
|
|
247
|
+
.bucketData(context.stream.replicationStreamId, definitionId)
|
|
248
|
+
.find({})
|
|
249
|
+
.toArray();
|
|
250
|
+
expect(documents).toHaveLength(1);
|
|
251
|
+
if (mode === 'offloaded') {
|
|
252
|
+
expect(documents[0].storage_ref).toBeDefined();
|
|
253
|
+
expect(documents[0].ops).toBeUndefined();
|
|
254
|
+
} else {
|
|
255
|
+
expect(documents[0].storage_ref).toBeUndefined();
|
|
256
|
+
expect(documents[0].ops).toMatchObject([{ op: 'PUT', row_id: 'A' }]);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
test.each([false, true])(
|
|
262
|
+
'replays parameter history and final membership after an abort (object storage: %s)',
|
|
263
|
+
async (enabled) => {
|
|
264
|
+
const objectStorage = new MemoryObjectStorage();
|
|
265
|
+
const context = await openStream(
|
|
266
|
+
storageVersion,
|
|
267
|
+
enabled ? objectStorage : undefined,
|
|
268
|
+
0,
|
|
269
|
+
`
|
|
270
|
+
config:
|
|
271
|
+
edition: 3
|
|
272
|
+
streams:
|
|
273
|
+
items:
|
|
274
|
+
query: SELECT * FROM items WHERE id IN (SELECT item_id FROM memberships WHERE user_id = auth.user_id())
|
|
275
|
+
`
|
|
276
|
+
);
|
|
277
|
+
await using _factory = context.factory;
|
|
278
|
+
await using writer = context.writer;
|
|
279
|
+
const membership = await test_utils.resolveTestTable(writer, 'memberships', ['id'], context.factoryGen, 2);
|
|
280
|
+
let publication: PreparedPublication | undefined;
|
|
281
|
+
const abort = new Error('abort parameter writes');
|
|
282
|
+
const originalPublish = PersistedBatchV3.prototype.publish;
|
|
283
|
+
using _flush = vi.spyOn(PersistedBatchV3.prototype, 'publish').mockImplementationOnce(async function (
|
|
284
|
+
this: PersistedBatchV3,
|
|
285
|
+
...args
|
|
286
|
+
) {
|
|
287
|
+
publication = this;
|
|
288
|
+
await originalPublish.apply(this, args);
|
|
289
|
+
throw abort;
|
|
290
|
+
});
|
|
291
|
+
for (const userId of ['old', 'new']) {
|
|
292
|
+
await writer.save({
|
|
293
|
+
sourceTable: membership,
|
|
294
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
295
|
+
after: { id: 'member', user_id: userId, item_id: 'item' },
|
|
296
|
+
afterReplicaId: test_utils.rid('member')
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
await expect(writer.commit('1/2')).rejects.toThrow(abort);
|
|
300
|
+
const { db } = context.bucketStorage;
|
|
301
|
+
const streamId = context.stream.replicationStreamId;
|
|
302
|
+
const [indexId] = context.stream.syncConfigContent[0].mapping.allParameterIndexIds();
|
|
303
|
+
expect(indexId).toBeDefined();
|
|
304
|
+
const parameters = db.parameterIndex(streamId, indexId);
|
|
305
|
+
expect(await parameters.countDocuments()).toBe(0);
|
|
306
|
+
await publication!.preparePayloads();
|
|
307
|
+
await db.client.withSession(async (session) => {
|
|
308
|
+
await session.withTransaction(() => publication!.publish(session));
|
|
309
|
+
});
|
|
310
|
+
const documents = await parameters.find({}).sort({ _id: 1 }).toArray();
|
|
311
|
+
expect(documents.map((doc) => doc._id)).toEqual([1n, 2n, 3n]);
|
|
312
|
+
expect(documents[0].bucket_parameters).not.toEqual([]);
|
|
313
|
+
expect(documents[1].bucket_parameters).toEqual(documents[0].bucket_parameters);
|
|
314
|
+
expect(documents[2].bucket_parameters).toEqual([]);
|
|
315
|
+
expect(documents[2].lookup).toEqual(documents[0].lookup);
|
|
316
|
+
expect(documents[1].lookup).not.toEqual(documents[0].lookup);
|
|
317
|
+
const records = await db
|
|
318
|
+
.sourceRecords(streamId, membership.id as bson.ObjectId)
|
|
319
|
+
.find({})
|
|
320
|
+
.toArray();
|
|
321
|
+
expect(records).toHaveLength(1);
|
|
322
|
+
expect(records[0].lookups).toHaveLength(1);
|
|
323
|
+
expect(records[0].lookups[0].l).toEqual(documents[1].lookup);
|
|
324
|
+
expect(objectStorage.store.size).toBe(0);
|
|
325
|
+
}
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
test.each([false, true])('joins sibling uploads before rejecting readiness (cancellation: %s)', async (cancelled) => {
|
|
329
|
+
const objectStorage = new MemoryObjectStorage();
|
|
330
|
+
const context = await openStream(storageVersion, objectStorage);
|
|
331
|
+
await using _factory = context.factory;
|
|
332
|
+
await using writer = context.writer;
|
|
333
|
+
let publication: PreparedPublication | undefined;
|
|
334
|
+
const seal = PersistedBatchV3.prototype.seal;
|
|
335
|
+
using _sealSpy = vi.spyOn(PersistedBatchV3.prototype, 'seal').mockImplementation(function (this: PersistedBatchV3) {
|
|
336
|
+
publication = seal.call(this);
|
|
337
|
+
return publication;
|
|
338
|
+
});
|
|
339
|
+
const failure = cancelled ? new DOMException('cancelled upload', 'AbortError') : new Error('failed upload');
|
|
340
|
+
const siblingStarted = Promise.withResolvers<void>();
|
|
341
|
+
const releaseSibling = Promise.withResolvers<void>();
|
|
342
|
+
let siblingFinished = false;
|
|
343
|
+
const put = objectStorage.put.bind(objectStorage);
|
|
344
|
+
using uploadSpy = vi
|
|
345
|
+
.spyOn(objectStorage, 'put')
|
|
346
|
+
.mockRejectedValueOnce(failure)
|
|
347
|
+
.mockImplementationOnce(async (...args) => {
|
|
348
|
+
siblingStarted.resolve();
|
|
349
|
+
await releaseSibling.promise;
|
|
350
|
+
await put(...args);
|
|
351
|
+
siblingFinished = true;
|
|
352
|
+
});
|
|
353
|
+
// A large first row forces two chunks without triggering input auto-flush.
|
|
354
|
+
for (const [id, size] of [
|
|
355
|
+
['A', 1_100_000],
|
|
356
|
+
['B', 1000]
|
|
357
|
+
] as const) {
|
|
358
|
+
await writer.save({
|
|
359
|
+
sourceTable: context.table,
|
|
360
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
361
|
+
after: { id, description: 'x'.repeat(size) },
|
|
362
|
+
afterReplicaId: test_utils.rid(id)
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const commit = writer.commit('1/2');
|
|
366
|
+
const rejected = expect(commit).rejects.toThrow(failure);
|
|
367
|
+
await siblingStarted.promise;
|
|
368
|
+
let readySettled = false;
|
|
369
|
+
const readiness = publication!.preparePayloads().then(
|
|
370
|
+
() => {
|
|
371
|
+
readySettled = true;
|
|
372
|
+
},
|
|
373
|
+
() => {
|
|
374
|
+
readySettled = true;
|
|
375
|
+
}
|
|
376
|
+
);
|
|
377
|
+
await Promise.resolve();
|
|
378
|
+
expect(readySettled).toBe(false);
|
|
379
|
+
releaseSibling.resolve();
|
|
380
|
+
await rejected;
|
|
381
|
+
await readiness;
|
|
382
|
+
expect(siblingFinished).toBe(true);
|
|
383
|
+
expect(uploadSpy).toHaveBeenCalledTimes(2);
|
|
384
|
+
await expect(publication!.preparePayloads()).rejects.toThrow(failure);
|
|
385
|
+
expect(uploadSpy).toHaveBeenCalledTimes(2);
|
|
386
|
+
expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
|
|
387
|
+
});
|
|
388
|
+
});
|