@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,829 @@
|
|
|
1
|
+
import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
|
|
2
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
3
|
+
import { logger as defaultLogger } 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 { MongoIdSequence } from '../../src/storage/implementation/MongoIdSequence.js';
|
|
9
|
+
import { OperationBatch } from '../../src/storage/implementation/OperationBatch.js';
|
|
10
|
+
import { MongoBucketBatchV3 } from '../../src/storage/implementation/v3/MongoBucketBatchV3.js';
|
|
11
|
+
import { MongoSyncBucketStorageV3 } from '../../src/storage/implementation/v3/MongoSyncBucketStorageV3.js';
|
|
12
|
+
import { PersistedBatchV3 } from '../../src/storage/implementation/v3/PersistedBatchV3.js';
|
|
13
|
+
import { SourceRecordStoreV3 } from '../../src/storage/implementation/v3/SourceRecordStoreV3.js';
|
|
14
|
+
import { env } from './env.js';
|
|
15
|
+
import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
|
|
16
|
+
import { PublicationCommandMonitor } from './helpers/PublicationCommandMonitor.js';
|
|
17
|
+
|
|
18
|
+
const CONFIG = `
|
|
19
|
+
config:
|
|
20
|
+
edition: 3
|
|
21
|
+
streams:
|
|
22
|
+
items:
|
|
23
|
+
query: SELECT * FROM items
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
async function open(version: number, enabled = false, options: Partial<storage.CreateWriterOptions> = {}) {
|
|
27
|
+
const objectStorage = new MemoryObjectStorage();
|
|
28
|
+
const factoryGen = mongoTestStorageFactoryGenerator({
|
|
29
|
+
url: env.MONGO_TEST_URL,
|
|
30
|
+
isCI: env.CI,
|
|
31
|
+
monitorCommands: true,
|
|
32
|
+
objectStorage: enabled ? objectStorage : undefined,
|
|
33
|
+
inlineThresholdBytes: 0
|
|
34
|
+
});
|
|
35
|
+
const factory = await factoryGen.factory();
|
|
36
|
+
const stream = await factory.updateSyncRules(updateSyncRulesFromYaml(CONFIG, { storageVersion: version }));
|
|
37
|
+
const bucketStorage = (await getTestStorage(factory, stream)) as MongoSyncBucketStorageV3;
|
|
38
|
+
const writer = (await bucketStorage.createWriter({ ...test_utils.BATCH_OPTIONS, ...options })) as MongoBucketBatchV3;
|
|
39
|
+
const table = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
|
|
40
|
+
await writer.markAllSnapshotDone('1/1');
|
|
41
|
+
await writer.save({
|
|
42
|
+
sourceTable: table,
|
|
43
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
44
|
+
after: { id: 'A', description: 'initial', retained: 'old' },
|
|
45
|
+
afterReplicaId: test_utils.rid('A')
|
|
46
|
+
});
|
|
47
|
+
await writer.flush();
|
|
48
|
+
await writer.commit('1/1');
|
|
49
|
+
return { factory, factoryGen, stream, bucketStorage, writer, table, objectStorage };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function save(writer: storage.BucketStorageBatch, table: storage.SourceTable, description: string) {
|
|
53
|
+
await writer.save({
|
|
54
|
+
sourceTable: table,
|
|
55
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
56
|
+
after: { id: 'A', description, retained: 'old' },
|
|
57
|
+
afterReplicaId: test_utils.rid('A')
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe.each([3, 4])('eager replication preparation v%s', (version) => {
|
|
62
|
+
test.each([false, true])(
|
|
63
|
+
'reads/evaluates once and delivers repairs after commit across rollback (object storage: %s)',
|
|
64
|
+
async (enabled) => {
|
|
65
|
+
const unavailable = vi.fn();
|
|
66
|
+
const context = await open(version, enabled, { markRecordUnavailable: unavailable });
|
|
67
|
+
await using factory = context.factory;
|
|
68
|
+
await using writer = context.writer;
|
|
69
|
+
using commands = new PublicationCommandMonitor(factory.db.client);
|
|
70
|
+
const read = SourceRecordStoreV3.prototype.loadDocuments;
|
|
71
|
+
using documents = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments').mockImplementation(function (
|
|
72
|
+
this: SourceRecordStoreV3,
|
|
73
|
+
...args
|
|
74
|
+
) {
|
|
75
|
+
expect(args[0].inTransaction()).toBe(false);
|
|
76
|
+
return read.apply(this, args);
|
|
77
|
+
});
|
|
78
|
+
const sizesRead = SourceRecordStoreV3.prototype.loadSizes;
|
|
79
|
+
using sizes = vi.spyOn(SourceRecordStoreV3.prototype, 'loadSizes').mockImplementation(function (
|
|
80
|
+
this: SourceRecordStoreV3,
|
|
81
|
+
...args
|
|
82
|
+
) {
|
|
83
|
+
expect(args[0].inTransaction()).toBe(false);
|
|
84
|
+
return sizesRead.apply(this, args);
|
|
85
|
+
});
|
|
86
|
+
using put = vi.spyOn(context.objectStorage, 'put');
|
|
87
|
+
const publish = PersistedBatchV3.prototype.publish;
|
|
88
|
+
let attempts = 0;
|
|
89
|
+
using _retry = vi.spyOn(PersistedBatchV3.prototype, 'publish').mockImplementation(async function (
|
|
90
|
+
this: PersistedBatchV3,
|
|
91
|
+
...args
|
|
92
|
+
) {
|
|
93
|
+
expect(unavailable).not.toHaveBeenCalled();
|
|
94
|
+
const result = await publish.apply(this, args);
|
|
95
|
+
if (++attempts === 1) {
|
|
96
|
+
throw new mongo.MongoServerError({
|
|
97
|
+
message: 'retry prepared writes',
|
|
98
|
+
code: 112,
|
|
99
|
+
errorLabels: ['TransientTransactionError']
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
});
|
|
104
|
+
await save(writer, context.table, 'updated');
|
|
105
|
+
await writer.save({
|
|
106
|
+
sourceTable: context.table,
|
|
107
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
108
|
+
after: { id: 'missing', description: undefined },
|
|
109
|
+
afterReplicaId: test_utils.rid('missing')
|
|
110
|
+
});
|
|
111
|
+
await writer.flush();
|
|
112
|
+
expect(attempts).toBe(2);
|
|
113
|
+
expect(documents).toHaveBeenCalledTimes(1);
|
|
114
|
+
expect(sizes).toHaveBeenCalledTimes(1);
|
|
115
|
+
expect(unavailable).toHaveBeenCalledTimes(1);
|
|
116
|
+
expect(put).toHaveBeenCalledTimes(enabled ? 1 : 0);
|
|
117
|
+
expect(writer.last_flushed_op).toBe(2n);
|
|
118
|
+
// The connector must be able to repair before advancing its source position.
|
|
119
|
+
expect(await factory.db.sync_rules.findOne({ _id: context.stream.replicationStreamId })).toMatchObject({
|
|
120
|
+
resume_lsn: '1/1',
|
|
121
|
+
sync_configs: [{ last_checkpoint: 1n, last_checkpoint_lsn: '1/1' }]
|
|
122
|
+
});
|
|
123
|
+
for (const attempt of commands.replicationAttempts) {
|
|
124
|
+
expect(attempt.commands.some((command) => ['find', 'aggregate'].includes(command.name))).toBe(false);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
test('logs oversized output during preparation without repeating it on publication retry', async () => {
|
|
130
|
+
const logger = defaultLogger.child({ test: 'oversized output diagnostics' });
|
|
131
|
+
const context = await open(version, false, { logger });
|
|
132
|
+
await using _factory = context.factory;
|
|
133
|
+
await using writer = context.writer;
|
|
134
|
+
using logged = vi.spyOn(logger, 'error');
|
|
135
|
+
const publish = PersistedBatchV3.prototype.publish;
|
|
136
|
+
let attempts = 0;
|
|
137
|
+
using _retry = vi.spyOn(PersistedBatchV3.prototype, 'publish').mockImplementation(async function (
|
|
138
|
+
this: PersistedBatchV3,
|
|
139
|
+
...args
|
|
140
|
+
) {
|
|
141
|
+
expect(logged).toHaveBeenCalledTimes(1);
|
|
142
|
+
const result = await publish.apply(this, args);
|
|
143
|
+
if (++attempts === 1) {
|
|
144
|
+
throw new mongo.MongoServerError({
|
|
145
|
+
message: 'retry oversized row removal',
|
|
146
|
+
code: 112,
|
|
147
|
+
errorLabels: ['TransientTransactionError']
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
});
|
|
152
|
+
// BSON can store this source row, but JSON escaping doubles its output size
|
|
153
|
+
// beyond the bucket-row limit. save() auto-flushes this large input.
|
|
154
|
+
await writer.save({
|
|
155
|
+
sourceTable: context.table,
|
|
156
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
157
|
+
after: { id: 'A', description: '"'.repeat(8 * 1024 * 1024), retained: 'old' },
|
|
158
|
+
afterReplicaId: test_utils.rid('A')
|
|
159
|
+
});
|
|
160
|
+
expect(attempts).toBe(2);
|
|
161
|
+
expect(logged).toHaveBeenCalledTimes(1);
|
|
162
|
+
expect(logged).toHaveBeenCalledWith(expect.stringMatching(/^Row .* too large: .* bytes\. Removing\.$/));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('requires explicit flush/repair before commit, including split batches', async () => {
|
|
166
|
+
const repairs: storage.SaveUpdate[] = [];
|
|
167
|
+
const context = await open(version, false, {
|
|
168
|
+
markRecordUnavailable: (record) => {
|
|
169
|
+
repairs.push(record);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
await using factory = context.factory;
|
|
173
|
+
await using writer = context.writer;
|
|
174
|
+
using _split = vi.spyOn(PersistedBatchV3.prototype, 'shouldFlushTransaction').mockReturnValue(true);
|
|
175
|
+
using reads = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments');
|
|
176
|
+
for (const id of ['missing1', 'missing2']) {
|
|
177
|
+
await writer.save({
|
|
178
|
+
sourceTable: context.table,
|
|
179
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
180
|
+
after: { id, description: undefined },
|
|
181
|
+
afterReplicaId: id
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
await expect(writer.commit('1/2')).rejects.toThrow('Flush pending rows');
|
|
185
|
+
expect(reads).not.toHaveBeenCalled();
|
|
186
|
+
expect(repairs).toEqual([]);
|
|
187
|
+
await writer.flush();
|
|
188
|
+
expect(repairs.map((record) => record.after.id)).toEqual(['missing1', 'missing2']);
|
|
189
|
+
expect(await factory.db.sync_rules.findOne({ _id: context.stream.replicationStreamId })).toMatchObject({
|
|
190
|
+
resume_lsn: '1/1',
|
|
191
|
+
sync_configs: [{ last_checkpoint: 1n }]
|
|
192
|
+
});
|
|
193
|
+
// Model WalStream's separate resnapshot phase, outside writer admission.
|
|
194
|
+
for (const repair of repairs) {
|
|
195
|
+
await writer.save({
|
|
196
|
+
sourceTable: repair.sourceTable,
|
|
197
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
198
|
+
after: { id: repair.after.id!, description: 'recovered' },
|
|
199
|
+
afterReplicaId: repair.afterReplicaId
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
await writer.flush();
|
|
203
|
+
await writer.commit('1/2');
|
|
204
|
+
expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(3n);
|
|
205
|
+
expect(repairs).toHaveLength(2);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test.each(['before-commit', 'after-commit'])('failed %s flush does not lose repair on replay', async (phase) => {
|
|
209
|
+
const unavailable = vi.fn();
|
|
210
|
+
const context = await open(version, false, { markRecordUnavailable: unavailable });
|
|
211
|
+
await using factory = context.factory;
|
|
212
|
+
await using writer = context.writer;
|
|
213
|
+
const failure = new Error('publication failed');
|
|
214
|
+
const publish = PersistedBatchV3.prototype.publish;
|
|
215
|
+
const withTransaction = writer.session.withTransaction.bind(writer.session);
|
|
216
|
+
using aborted = vi.spyOn(PersistedBatchV3.prototype, 'publish');
|
|
217
|
+
using uncertain = vi.spyOn(writer.session, 'withTransaction');
|
|
218
|
+
if (phase === 'before-commit') {
|
|
219
|
+
aborted.mockImplementationOnce(async function (this: PersistedBatchV3, ...args) {
|
|
220
|
+
await publish.apply(this, args);
|
|
221
|
+
throw failure;
|
|
222
|
+
});
|
|
223
|
+
} else {
|
|
224
|
+
// Simulate a terminal lost commit response: the data committed, but the
|
|
225
|
+
// writer cannot confirm it and therefore must not deliver repairs.
|
|
226
|
+
uncertain.mockImplementationOnce(async (...args) => {
|
|
227
|
+
await withTransaction(...args);
|
|
228
|
+
throw failure;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
await writer.save({
|
|
232
|
+
sourceTable: context.table,
|
|
233
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
234
|
+
after: { id: 'missing', description: undefined },
|
|
235
|
+
afterReplicaId: 'missing'
|
|
236
|
+
});
|
|
237
|
+
await expect(writer.flush()).rejects.toThrow(failure);
|
|
238
|
+
expect(unavailable).not.toHaveBeenCalled();
|
|
239
|
+
const collection = context.bucketStorage.db.sourceRecords(
|
|
240
|
+
context.stream.replicationStreamId,
|
|
241
|
+
context.table.id as bson.ObjectId
|
|
242
|
+
);
|
|
243
|
+
const partial = (await collection.find({}).toArray()).find((record) => record._id === 'missing');
|
|
244
|
+
expect(partial != null).toBe(phase === 'after-commit');
|
|
245
|
+
expect(await factory.db.sync_rules.findOne({ _id: context.stream.replicationStreamId })).toMatchObject({
|
|
246
|
+
resume_lsn: '1/1',
|
|
247
|
+
sync_configs: [{ last_checkpoint: 1n }]
|
|
248
|
+
});
|
|
249
|
+
aborted.mockRestore();
|
|
250
|
+
uncertain.mockRestore();
|
|
251
|
+
await expect(writer.setResumeLsn('1/2')).rejects.toThrow('restart replication');
|
|
252
|
+
await test_utils.releaseTestStorageLease(factory, context.stream.replicationStreamId);
|
|
253
|
+
const replayStorage = await getTestStorage(factory, (await factory.getReplicatingReplicationStreams())[0]);
|
|
254
|
+
const repairs = vi.fn();
|
|
255
|
+
await using replay = await replayStorage.createWriter({
|
|
256
|
+
...test_utils.BATCH_OPTIONS,
|
|
257
|
+
markRecordUnavailable: repairs
|
|
258
|
+
});
|
|
259
|
+
const replayTable = await test_utils.resolveTestTable(replay, 'items', ['id'], context.factoryGen, 1);
|
|
260
|
+
await replay.save({
|
|
261
|
+
sourceTable: replayTable,
|
|
262
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
263
|
+
after: { id: 'missing', description: undefined },
|
|
264
|
+
afterReplicaId: 'missing'
|
|
265
|
+
});
|
|
266
|
+
await replay.flush();
|
|
267
|
+
expect(repairs).toHaveBeenCalledTimes(1);
|
|
268
|
+
await replay.save({
|
|
269
|
+
sourceTable: replayTable,
|
|
270
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
271
|
+
after: { id: 'missing', description: 'repaired' },
|
|
272
|
+
afterReplicaId: 'missing'
|
|
273
|
+
});
|
|
274
|
+
await replay.flush();
|
|
275
|
+
await replay.commit('1/2');
|
|
276
|
+
expect((await context.bucketStorage.getCheckpoint()).lsn).toBe('1/2');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('a throwing repair callback stops all writers sharing the lease, including queued progress', async () => {
|
|
280
|
+
const failure = new Error('repair callback failed');
|
|
281
|
+
const callback = () => {
|
|
282
|
+
throw failure;
|
|
283
|
+
};
|
|
284
|
+
const context = await open(version, false, { markRecordUnavailable: callback });
|
|
285
|
+
await using factory = context.factory;
|
|
286
|
+
await using writer = context.writer;
|
|
287
|
+
await using other = await context.bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
288
|
+
const entered = Promise.withResolvers<void>();
|
|
289
|
+
const release = Promise.withResolvers<void>();
|
|
290
|
+
const load = SourceRecordStoreV3.prototype.loadDocuments;
|
|
291
|
+
using _pause = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments').mockImplementationOnce(async function (
|
|
292
|
+
this: SourceRecordStoreV3,
|
|
293
|
+
...args
|
|
294
|
+
) {
|
|
295
|
+
const records = await load.apply(this, args);
|
|
296
|
+
entered.resolve();
|
|
297
|
+
await release.promise;
|
|
298
|
+
return records;
|
|
299
|
+
});
|
|
300
|
+
await writer.save({
|
|
301
|
+
sourceTable: context.table,
|
|
302
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
303
|
+
after: { id: 'missing', description: undefined },
|
|
304
|
+
afterReplicaId: 'missing'
|
|
305
|
+
});
|
|
306
|
+
const flushing = expect(writer.flush()).rejects.toThrow(failure);
|
|
307
|
+
await entered.promise;
|
|
308
|
+
const queued = expect(other.setResumeLsn('1/2')).rejects.toThrow('restart replication');
|
|
309
|
+
release.resolve();
|
|
310
|
+
await Promise.all([flushing, queued]);
|
|
311
|
+
await expect(writer.commit('1/2')).rejects.toThrow('restart replication');
|
|
312
|
+
await expect(writer.keepalive('1/2')).rejects.toThrow('restart replication');
|
|
313
|
+
await expect(writer.setResumeLsn('1/2')).rejects.toThrow('restart replication');
|
|
314
|
+
await expect(other.commit('1/2')).rejects.toThrow('restart replication');
|
|
315
|
+
expect(await factory.db.sync_rules.findOne({ _id: context.stream.replicationStreamId })).toMatchObject({
|
|
316
|
+
resume_lsn: '1/1',
|
|
317
|
+
sync_configs: [{ last_checkpoint: 1n }]
|
|
318
|
+
});
|
|
319
|
+
const repairs = vi.fn();
|
|
320
|
+
await test_utils.releaseTestStorageLease(factory, context.stream.replicationStreamId);
|
|
321
|
+
const restarted = await getTestStorage(factory, context.stream);
|
|
322
|
+
await using replay = await restarted.createWriter({
|
|
323
|
+
...test_utils.BATCH_OPTIONS,
|
|
324
|
+
markRecordUnavailable: repairs
|
|
325
|
+
});
|
|
326
|
+
await replay.save({
|
|
327
|
+
sourceTable: context.table,
|
|
328
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
329
|
+
after: { id: 'missing', description: undefined },
|
|
330
|
+
afterReplicaId: 'missing'
|
|
331
|
+
});
|
|
332
|
+
await replay.flush();
|
|
333
|
+
expect(repairs).toHaveBeenCalledTimes(1);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('discards an unfinished row notification when operation IDs run out', async () => {
|
|
337
|
+
const repairs = vi.fn();
|
|
338
|
+
const context = await open(version, false, { markRecordUnavailable: repairs });
|
|
339
|
+
await using factory = context.factory;
|
|
340
|
+
await using writer = context.writer;
|
|
341
|
+
const allocator = factory.getOpIdAllocator(
|
|
342
|
+
context.bucketStorage.replicationStream,
|
|
343
|
+
context.bucketStorage.replicationStream.current_lock!
|
|
344
|
+
);
|
|
345
|
+
using sequence = vi.spyOn(allocator, 'sequence').mockImplementationOnce((head) => new MongoIdSequence(head, []));
|
|
346
|
+
await writer.save({
|
|
347
|
+
sourceTable: context.table,
|
|
348
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
349
|
+
after: { id: 'new', description: undefined },
|
|
350
|
+
beforeReplicaId: 'old',
|
|
351
|
+
afterReplicaId: 'new'
|
|
352
|
+
});
|
|
353
|
+
await writer.flush();
|
|
354
|
+
expect(sequence).toHaveBeenCalledTimes(2);
|
|
355
|
+
expect(repairs).toHaveBeenCalledTimes(1);
|
|
356
|
+
expect(repairs.mock.calls[0][0].afterReplicaId).toBe('new');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('disposing one writer preserves the lease session for other writers', async () => {
|
|
360
|
+
const context = await open(version);
|
|
361
|
+
await using factory = context.factory;
|
|
362
|
+
await using writer = context.writer;
|
|
363
|
+
const disposable = (await context.bucketStorage.createWriter(test_utils.BATCH_OPTIONS)) as MongoBucketBatchV3;
|
|
364
|
+
const session = writer.session;
|
|
365
|
+
expect(disposable.session).toBe(session);
|
|
366
|
+
await disposable.dispose();
|
|
367
|
+
expect(session.hasEnded).toBe(false);
|
|
368
|
+
await save(writer, context.table, 'after writer disposal');
|
|
369
|
+
await writer.commit('1/2');
|
|
370
|
+
expect((await context.bucketStorage.getCheckpoint()).lsn).toBe('1/2');
|
|
371
|
+
await test_utils.releaseTestStorageLease(factory, context.stream.replicationStreamId);
|
|
372
|
+
expect(session.hasEnded).toBe(true);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('lease release waits for active session use and rejects queued operations', async () => {
|
|
376
|
+
const context = await open(version);
|
|
377
|
+
await using _factory = context.factory;
|
|
378
|
+
await using writer = context.writer;
|
|
379
|
+
await using other = await context.bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
380
|
+
const lease = context.bucketStorage.replicationStream.current_lock!;
|
|
381
|
+
const entered = Promise.withResolvers<void>();
|
|
382
|
+
const release = Promise.withResolvers<void>();
|
|
383
|
+
const load = SourceRecordStoreV3.prototype.loadDocuments;
|
|
384
|
+
using _pause = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments').mockImplementationOnce(async function (
|
|
385
|
+
this: SourceRecordStoreV3,
|
|
386
|
+
...args
|
|
387
|
+
) {
|
|
388
|
+
const result = await load.apply(this, args);
|
|
389
|
+
entered.resolve();
|
|
390
|
+
await release.promise;
|
|
391
|
+
return result;
|
|
392
|
+
});
|
|
393
|
+
await save(writer, context.table, 'abandoned during release');
|
|
394
|
+
const publishing = expect(writer.flush()).rejects.toThrow('Replication lock released');
|
|
395
|
+
await entered.promise;
|
|
396
|
+
const queued = expect(other.getSourceTableStatus(context.table)).rejects.toThrow('Replication lock released');
|
|
397
|
+
const releasing = lease.release();
|
|
398
|
+
try {
|
|
399
|
+
expect(writer.session.hasEnded).toBe(false);
|
|
400
|
+
release.resolve();
|
|
401
|
+
await Promise.all([publishing, queued, releasing]);
|
|
402
|
+
expect(writer.session.hasEnded).toBe(true);
|
|
403
|
+
} finally {
|
|
404
|
+
release.resolve();
|
|
405
|
+
await Promise.allSettled([publishing, queued, releasing]);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test('uses the shared causal session across writers and majority preparation reads', async () => {
|
|
410
|
+
const context = await open(version);
|
|
411
|
+
await using factory = context.factory;
|
|
412
|
+
await using first = context.writer;
|
|
413
|
+
await using second = (await context.bucketStorage.createWriter(test_utils.BATCH_OPTIONS)) as MongoBucketBatchV3;
|
|
414
|
+
expect(second.session).toBe(first.session);
|
|
415
|
+
await save(first, context.table, 'second publication');
|
|
416
|
+
await first.commit('1/2');
|
|
417
|
+
const committedTime = first.session.operationTime!;
|
|
418
|
+
const reads: mongo.Document[] = [];
|
|
419
|
+
const observe = (event: mongo.CommandStartedEvent) => {
|
|
420
|
+
if (
|
|
421
|
+
(event.commandName === 'find' && event.command.find.startsWith('source_records_')) ||
|
|
422
|
+
event.commandName === 'aggregate'
|
|
423
|
+
) {
|
|
424
|
+
reads.push(event.command);
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
factory.db.client.on('commandStarted', observe);
|
|
428
|
+
try {
|
|
429
|
+
await second.save({
|
|
430
|
+
sourceTable: context.table,
|
|
431
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
432
|
+
after: { id: undefined, description: undefined, retained: 'new' },
|
|
433
|
+
afterReplicaId: test_utils.rid('A')
|
|
434
|
+
});
|
|
435
|
+
await second.commit('1/3');
|
|
436
|
+
} finally {
|
|
437
|
+
factory.db.client.off('commandStarted', observe);
|
|
438
|
+
}
|
|
439
|
+
expect(reads).toHaveLength(2);
|
|
440
|
+
for (const read of reads) {
|
|
441
|
+
expect(read.lsid).toEqual(first.session.id);
|
|
442
|
+
expect(read.autocommit).toBeUndefined();
|
|
443
|
+
expect(read.readConcern.level).toBe('majority');
|
|
444
|
+
expect(read.readConcern.afterClusterTime.compare(committedTime)).toBeGreaterThanOrEqual(0);
|
|
445
|
+
}
|
|
446
|
+
const [record] = await context.bucketStorage.db
|
|
447
|
+
.sourceRecords(context.stream.replicationStreamId, context.table.id as bson.ObjectId)
|
|
448
|
+
.find({})
|
|
449
|
+
.toArray();
|
|
450
|
+
expect(bson.deserialize(record.data!.buffer)).toEqual({
|
|
451
|
+
id: 'A',
|
|
452
|
+
description: 'second publication',
|
|
453
|
+
retained: 'new'
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test.each(['metadata', 'truncate', 'drop'])(
|
|
458
|
+
'%s waits for prepared work, including physical table cleanup',
|
|
459
|
+
async (mutation) => {
|
|
460
|
+
const context = await open(version);
|
|
461
|
+
await using factory = context.factory;
|
|
462
|
+
await using writer = context.writer;
|
|
463
|
+
await using other = (await context.bucketStorage.createWriter(test_utils.BATCH_OPTIONS)) as MongoBucketBatchV3;
|
|
464
|
+
const entered = Promise.withResolvers<void>();
|
|
465
|
+
const release = Promise.withResolvers<void>();
|
|
466
|
+
const read = SourceRecordStoreV3.prototype.loadDocuments;
|
|
467
|
+
using _pause = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments').mockImplementationOnce(async function (
|
|
468
|
+
this: SourceRecordStoreV3,
|
|
469
|
+
...args
|
|
470
|
+
) {
|
|
471
|
+
const result = await read.apply(this, args);
|
|
472
|
+
entered.resolve();
|
|
473
|
+
await release.promise;
|
|
474
|
+
return result;
|
|
475
|
+
});
|
|
476
|
+
using commands = new PublicationCommandMonitor(factory.db.client);
|
|
477
|
+
await save(writer, context.table, 'prepared');
|
|
478
|
+
const publishing = writer.flush();
|
|
479
|
+
const pending: Promise<unknown>[] = [publishing];
|
|
480
|
+
try {
|
|
481
|
+
await entered.promise;
|
|
482
|
+
expect(commands.activeReplicationAttempts).toHaveLength(0);
|
|
483
|
+
using transactions = vi.spyOn(other.session, 'withTransaction');
|
|
484
|
+
pending.push(
|
|
485
|
+
mutation === 'metadata'
|
|
486
|
+
? other.markTableSnapshotDone([context.table])
|
|
487
|
+
: mutation === 'truncate'
|
|
488
|
+
? other.truncate([context.table])
|
|
489
|
+
: other.drop([context.table])
|
|
490
|
+
);
|
|
491
|
+
expect(transactions).not.toHaveBeenCalled();
|
|
492
|
+
release.resolve();
|
|
493
|
+
await Promise.all(pending);
|
|
494
|
+
expect(transactions).toHaveBeenCalled();
|
|
495
|
+
} finally {
|
|
496
|
+
release.resolve();
|
|
497
|
+
await Promise.allSettled(pending);
|
|
498
|
+
}
|
|
499
|
+
if (mutation === 'drop') {
|
|
500
|
+
expect(
|
|
501
|
+
await context.bucketStorage.db.db
|
|
502
|
+
.listCollections({
|
|
503
|
+
name: context.bucketStorage.db.sourceRecords(
|
|
504
|
+
context.stream.replicationStreamId,
|
|
505
|
+
context.table.id as bson.ObjectId
|
|
506
|
+
).collectionName
|
|
507
|
+
})
|
|
508
|
+
.hasNext()
|
|
509
|
+
).toBe(false);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
test('soft-delete cleanup holds admission until its database work finishes', async () => {
|
|
515
|
+
const context = await open(version);
|
|
516
|
+
await using _factory = context.factory;
|
|
517
|
+
await using writer = context.writer;
|
|
518
|
+
await using other = await context.bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
519
|
+
const entered = Promise.withResolvers<void>();
|
|
520
|
+
const release = Promise.withResolvers<void>();
|
|
521
|
+
const cleanup = SourceRecordStoreV3.prototype.postCommitCleanup;
|
|
522
|
+
using _pause = vi.spyOn(SourceRecordStoreV3.prototype, 'postCommitCleanup').mockImplementationOnce(async function (
|
|
523
|
+
this: SourceRecordStoreV3,
|
|
524
|
+
...args
|
|
525
|
+
) {
|
|
526
|
+
entered.resolve();
|
|
527
|
+
await release.promise;
|
|
528
|
+
return cleanup.apply(this, args);
|
|
529
|
+
});
|
|
530
|
+
const cleaning = other.commit('1/2');
|
|
531
|
+
const pending: Promise<unknown>[] = [cleaning];
|
|
532
|
+
try {
|
|
533
|
+
await entered.promise;
|
|
534
|
+
using read = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments');
|
|
535
|
+
await save(writer, context.table, 'after cleanup');
|
|
536
|
+
pending.push(writer.flush());
|
|
537
|
+
expect(read).not.toHaveBeenCalled();
|
|
538
|
+
release.resolve();
|
|
539
|
+
await Promise.all(pending);
|
|
540
|
+
expect(read).toHaveBeenCalledTimes(1);
|
|
541
|
+
} finally {
|
|
542
|
+
release.resolve();
|
|
543
|
+
await Promise.allSettled(pending);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test.each(['config', 'lease'])('rejects stale %s after preparation without rereading records', async (change) => {
|
|
548
|
+
const context = await open(version, true);
|
|
549
|
+
await using factory = context.factory;
|
|
550
|
+
await using writer = context.writer;
|
|
551
|
+
const entered = Promise.withResolvers<void>();
|
|
552
|
+
const release = Promise.withResolvers<void>();
|
|
553
|
+
const original = SourceRecordStoreV3.prototype.loadDocuments;
|
|
554
|
+
using read = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments').mockImplementationOnce(async function (
|
|
555
|
+
this: SourceRecordStoreV3,
|
|
556
|
+
...args
|
|
557
|
+
) {
|
|
558
|
+
const result = await original.apply(this, args);
|
|
559
|
+
entered.resolve();
|
|
560
|
+
await release.promise;
|
|
561
|
+
return result;
|
|
562
|
+
});
|
|
563
|
+
using put = vi.spyOn(context.objectStorage, 'put');
|
|
564
|
+
await save(writer, context.table, 'stale');
|
|
565
|
+
const publishing = writer.flush();
|
|
566
|
+
const rejected = expect(publishing).rejects.toThrow(change === 'lease' ? 'no longer owns' : 'reload');
|
|
567
|
+
try {
|
|
568
|
+
await entered.promise;
|
|
569
|
+
await factory.db.sync_rules.updateOne(
|
|
570
|
+
{ _id: context.stream.replicationStreamId },
|
|
571
|
+
{
|
|
572
|
+
$set:
|
|
573
|
+
change === 'lease'
|
|
574
|
+
? { 'lock.id': 'replacement-owner' }
|
|
575
|
+
: { 'sync_configs.$[].state': storage.SyncRuleState.STOP }
|
|
576
|
+
},
|
|
577
|
+
{ writeConcern: { w: 'majority' } }
|
|
578
|
+
);
|
|
579
|
+
release.resolve();
|
|
580
|
+
await rejected;
|
|
581
|
+
expect(read).toHaveBeenCalledTimes(1);
|
|
582
|
+
// Ownership/config are revalidated after uploads. Work abandoned at that
|
|
583
|
+
// boundary retains its marker instead of publishing a stale reference.
|
|
584
|
+
expect(put).toHaveBeenCalledTimes(1);
|
|
585
|
+
expect(
|
|
586
|
+
await context.bucketStorage.db
|
|
587
|
+
.pendingObjectStorageDeletes(context.stream.replicationStreamId)
|
|
588
|
+
.countDocuments({ path: put.mock.calls[0][0] })
|
|
589
|
+
).toBe(1);
|
|
590
|
+
expect(writer.last_flushed_op).toBe(1n);
|
|
591
|
+
} finally {
|
|
592
|
+
release.resolve();
|
|
593
|
+
await Promise.allSettled([publishing]);
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test('uses fresh fenced snapshot metadata for the checkpoint decision', async () => {
|
|
598
|
+
const context = await open(version);
|
|
599
|
+
await using factory = context.factory;
|
|
600
|
+
await using writer = context.writer;
|
|
601
|
+
const read = SourceRecordStoreV3.prototype.loadDocuments;
|
|
602
|
+
using update = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments').mockImplementationOnce(async function (
|
|
603
|
+
this: SourceRecordStoreV3,
|
|
604
|
+
...args
|
|
605
|
+
) {
|
|
606
|
+
const records = await read.apply(this, args);
|
|
607
|
+
await factory.db.sync_rules.updateOne(
|
|
608
|
+
{ _id: context.stream.replicationStreamId },
|
|
609
|
+
{ $set: { 'sync_configs.$[].snapshot_done': false } },
|
|
610
|
+
{ writeConcern: { w: 'majority' } }
|
|
611
|
+
);
|
|
612
|
+
return records;
|
|
613
|
+
});
|
|
614
|
+
await save(writer, context.table, 'not visible yet');
|
|
615
|
+
expect((await writer.commit('1/2')).checkpointBlocked).toBe(true);
|
|
616
|
+
expect(writer.last_flushed_op).toBe(2n);
|
|
617
|
+
expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
|
|
618
|
+
expect(await factory.db.sync_rules.findOne({ _id: context.stream.replicationStreamId })).toMatchObject({
|
|
619
|
+
last_persisted_op: 2n,
|
|
620
|
+
sync_configs: [{ last_checkpoint: 1n, last_checkpoint_lsn: '1/1', snapshot_done: false }]
|
|
621
|
+
});
|
|
622
|
+
await writer.markAllSnapshotDone('1/2');
|
|
623
|
+
expect((await writer.commit('1/3')).checkpointBlocked).toBe(false);
|
|
624
|
+
expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(2n);
|
|
625
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test('preserves read partitions and repeated-row state when a later data bulk rolls back', async () => {
|
|
629
|
+
const context = await open(version, true);
|
|
630
|
+
await using factory = context.factory;
|
|
631
|
+
await using writer = context.writer;
|
|
632
|
+
using _blocks = vi.spyOn(OperationBatch.prototype, 'batched').mockImplementation(function* (this: OperationBatch) {
|
|
633
|
+
for (const op of this.batch) {
|
|
634
|
+
yield [op];
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
using reads = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments');
|
|
638
|
+
using monitor = new PublicationCommandMonitor(factory.db.client);
|
|
639
|
+
const put = context.objectStorage.put.bind(context.objectStorage);
|
|
640
|
+
using uploads = vi.spyOn(context.objectStorage, 'put').mockImplementation(async (...args) => {
|
|
641
|
+
// All groups in this prefix must finish uploading before its first fence,
|
|
642
|
+
// even though each group later emits a separate data bulk on every retry.
|
|
643
|
+
expect(monitor.activeReplicationAttempts).toHaveLength(0);
|
|
644
|
+
await put(...args);
|
|
645
|
+
});
|
|
646
|
+
const publish = PersistedBatchV3.prototype.publish;
|
|
647
|
+
let writes = 0;
|
|
648
|
+
using _retry = vi.spyOn(PersistedBatchV3.prototype, 'publish').mockImplementation(async function (
|
|
649
|
+
this: PersistedBatchV3,
|
|
650
|
+
...args
|
|
651
|
+
) {
|
|
652
|
+
const result = await publish.apply(this, args);
|
|
653
|
+
if (++writes === 2) {
|
|
654
|
+
throw new mongo.MongoServerError({
|
|
655
|
+
message: 'rollback both blocks',
|
|
656
|
+
code: 112,
|
|
657
|
+
errorLabels: ['TransientTransactionError']
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
return result;
|
|
661
|
+
});
|
|
662
|
+
await save(writer, context.table, 'first block');
|
|
663
|
+
await writer.save({
|
|
664
|
+
sourceTable: context.table,
|
|
665
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
666
|
+
after: { id: undefined, description: undefined, retained: 'second block' },
|
|
667
|
+
afterReplicaId: test_utils.rid('A')
|
|
668
|
+
});
|
|
669
|
+
await writer.commit('1/2');
|
|
670
|
+
expect(reads).toHaveBeenCalledTimes(1);
|
|
671
|
+
expect(writes).toBe(4);
|
|
672
|
+
expect(uploads).toHaveBeenCalledTimes(2);
|
|
673
|
+
const db = context.bucketStorage.db;
|
|
674
|
+
const [record] = await db
|
|
675
|
+
.sourceRecords(context.stream.replicationStreamId, context.table.id as bson.ObjectId)
|
|
676
|
+
.find({})
|
|
677
|
+
.toArray();
|
|
678
|
+
expect(bson.deserialize(record.data!.buffer)).toEqual({
|
|
679
|
+
id: 'A',
|
|
680
|
+
description: 'first block',
|
|
681
|
+
retained: 'second block'
|
|
682
|
+
});
|
|
683
|
+
expect(await db.bucketState(context.stream.replicationStreamId).findOne({})).toMatchObject({
|
|
684
|
+
last_op: 3n,
|
|
685
|
+
bucket_stats: { count: 3, chunks: 3 }
|
|
686
|
+
});
|
|
687
|
+
expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(3n);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test('a replacement lease publishes while the previous owner is preparing', async () => {
|
|
691
|
+
const context = await open(version, true);
|
|
692
|
+
await using factory = context.factory;
|
|
693
|
+
await using writer = context.writer;
|
|
694
|
+
const entered = Promise.withResolvers<void>();
|
|
695
|
+
const release = Promise.withResolvers<void>();
|
|
696
|
+
const load = SourceRecordStoreV3.prototype.loadDocuments;
|
|
697
|
+
using read = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments').mockImplementationOnce(async function (
|
|
698
|
+
this: SourceRecordStoreV3,
|
|
699
|
+
...args
|
|
700
|
+
) {
|
|
701
|
+
const result = await load.apply(this, args);
|
|
702
|
+
entered.resolve();
|
|
703
|
+
await release.promise;
|
|
704
|
+
return result;
|
|
705
|
+
});
|
|
706
|
+
using put = vi.spyOn(context.objectStorage, 'put');
|
|
707
|
+
await save(writer, context.table, 'previous owner');
|
|
708
|
+
const pending = writer.flush();
|
|
709
|
+
const rejected = expect(pending).rejects.toThrow('no longer owns');
|
|
710
|
+
try {
|
|
711
|
+
await entered.promise;
|
|
712
|
+
await factory.db.sync_rules.updateOne(
|
|
713
|
+
{ _id: context.stream.replicationStreamId },
|
|
714
|
+
{ $set: { 'lock.expires_at': new Date(0) } }
|
|
715
|
+
);
|
|
716
|
+
await using replacement = await context.factoryGen.factory({ doNotClear: true });
|
|
717
|
+
const stream = (await replacement.getReplicatingReplicationStreams())[0];
|
|
718
|
+
const lease = await stream.lock();
|
|
719
|
+
await using _leaseLifetime = { [Symbol.asyncDispose]: () => lease.release() };
|
|
720
|
+
const storage = replacement.getInstance(stream, { replicationLock: lease });
|
|
721
|
+
await using next = await storage.createWriter(test_utils.BATCH_OPTIONS);
|
|
722
|
+
const table = await test_utils.resolveTestTable(next, 'items', ['id'], context.factoryGen, 1);
|
|
723
|
+
await save(next, table, 'replacement owner');
|
|
724
|
+
await next.commit('1/2');
|
|
725
|
+
expect(next.last_flushed_op).toBeGreaterThan(65_536n);
|
|
726
|
+
expect(put).toHaveBeenCalledTimes(1);
|
|
727
|
+
release.resolve();
|
|
728
|
+
await rejected;
|
|
729
|
+
expect(put).toHaveBeenCalledTimes(2);
|
|
730
|
+
expect(read).toHaveBeenCalledTimes(2);
|
|
731
|
+
expect(writer.last_flushed_op).toBe(1n);
|
|
732
|
+
const records = await context.bucketStorage.db
|
|
733
|
+
.sourceRecords(context.stream.replicationStreamId, context.table.id as bson.ObjectId)
|
|
734
|
+
.find({})
|
|
735
|
+
.toArray();
|
|
736
|
+
expect(bson.deserialize(records[0].data!.buffer).description).toBe('replacement owner');
|
|
737
|
+
} finally {
|
|
738
|
+
release.resolve();
|
|
739
|
+
await Promise.allSettled([pending]);
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test('retains a snapshot tombstone observation if previous-owner cleanup removes it during preparation', async () => {
|
|
744
|
+
const context = await open(version);
|
|
745
|
+
await using _factory = context.factory;
|
|
746
|
+
await using _writer = context.writer;
|
|
747
|
+
const db = context.bucketStorage.db;
|
|
748
|
+
const streamId = context.stream.replicationStreamId;
|
|
749
|
+
const sourceId = context.table.id as bson.ObjectId;
|
|
750
|
+
const collection = db.sourceRecords(streamId, sourceId);
|
|
751
|
+
await collection.insertOne({ _id: 'deleted', data: null, buckets: [], lookups: [], pending_delete: 1n });
|
|
752
|
+
await db.sourceTables(streamId).updateOne({ _id: sourceId }, { $set: { latest_pending_delete: 1n } });
|
|
753
|
+
await using snapshot = await context.bucketStorage.createWriter({
|
|
754
|
+
...test_utils.BATCH_OPTIONS,
|
|
755
|
+
skipExistingRows: true
|
|
756
|
+
});
|
|
757
|
+
const cleanup = new SourceRecordStoreV3(
|
|
758
|
+
db,
|
|
759
|
+
streamId,
|
|
760
|
+
context.bucketStorage.getParsedSyncConfigSet(test_utils.BATCH_OPTIONS).mapping
|
|
761
|
+
);
|
|
762
|
+
const load = SourceRecordStoreV3.prototype.loadDocuments;
|
|
763
|
+
using read = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments').mockImplementationOnce(async function (
|
|
764
|
+
this: SourceRecordStoreV3,
|
|
765
|
+
...args
|
|
766
|
+
) {
|
|
767
|
+
const records = await load.apply(this, args);
|
|
768
|
+
// A previous process does not share this lease's local admission mutex.
|
|
769
|
+
await cleanup.postCommitCleanup(1n, context.stream.logger);
|
|
770
|
+
expect((await collection.find({}).toArray()).find((row) => row._id === 'deleted') ?? null).toBeNull();
|
|
771
|
+
return records;
|
|
772
|
+
});
|
|
773
|
+
await snapshot.save({
|
|
774
|
+
sourceTable: context.table,
|
|
775
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
776
|
+
after: { id: 'deleted', description: 'old snapshot' },
|
|
777
|
+
afterReplicaId: 'deleted'
|
|
778
|
+
});
|
|
779
|
+
await snapshot.flush();
|
|
780
|
+
expect(read).toHaveBeenCalledTimes(1);
|
|
781
|
+
expect((await collection.find({}).toArray()).find((row) => row._id === 'deleted') ?? null).toBeNull();
|
|
782
|
+
expect(snapshot.last_flushed_op).toBe(1n);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test('previous-owner cleanup cannot delete a newly live record', async () => {
|
|
786
|
+
const context = await open(version);
|
|
787
|
+
await using _factory = context.factory;
|
|
788
|
+
await using writer = context.writer;
|
|
789
|
+
const db = context.bucketStorage.db;
|
|
790
|
+
const streamId = context.stream.replicationStreamId;
|
|
791
|
+
const sourceId = context.table.id as bson.ObjectId;
|
|
792
|
+
const collection = db.sourceRecords(streamId, sourceId);
|
|
793
|
+
await collection.insertOne({ _id: 'resurrected', data: null, buckets: [], lookups: [], pending_delete: 1n });
|
|
794
|
+
await db.sourceTables(streamId).updateOne({ _id: sourceId }, { $set: { latest_pending_delete: 1n } });
|
|
795
|
+
using _collections = vi.spyOn(db, 'sourceRecords').mockReturnValue(collection);
|
|
796
|
+
const entered = Promise.withResolvers<void>();
|
|
797
|
+
const release = Promise.withResolvers<void>();
|
|
798
|
+
const deleteMany = collection.deleteMany.bind(collection);
|
|
799
|
+
using _deletion = vi.spyOn(collection, 'deleteMany').mockImplementationOnce(async (...args) => {
|
|
800
|
+
entered.resolve();
|
|
801
|
+
await release.promise;
|
|
802
|
+
return deleteMany(...args);
|
|
803
|
+
});
|
|
804
|
+
const cleanup = new SourceRecordStoreV3(
|
|
805
|
+
db,
|
|
806
|
+
streamId,
|
|
807
|
+
context.bucketStorage.getParsedSyncConfigSet(test_utils.BATCH_OPTIONS).mapping
|
|
808
|
+
);
|
|
809
|
+
const cleaning = cleanup.postCommitCleanup(1n, context.stream.logger);
|
|
810
|
+
try {
|
|
811
|
+
await entered.promise;
|
|
812
|
+
await writer.save({
|
|
813
|
+
sourceTable: context.table,
|
|
814
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
815
|
+
after: { id: 'resurrected', description: 'live' },
|
|
816
|
+
afterReplicaId: 'resurrected'
|
|
817
|
+
});
|
|
818
|
+
await writer.flush();
|
|
819
|
+
release.resolve();
|
|
820
|
+
await cleaning;
|
|
821
|
+
const record = (await collection.find({}).toArray()).find((row) => row._id === 'resurrected');
|
|
822
|
+
expect(record!.pending_delete).toBeUndefined();
|
|
823
|
+
expect(bson.deserialize(record!.data!.buffer)).toEqual({ id: 'resurrected', description: 'live' });
|
|
824
|
+
} finally {
|
|
825
|
+
release.resolve();
|
|
826
|
+
await Promise.allSettled([cleaning]);
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
});
|