@powersync/service-module-mongodb-storage 0.23.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/LICENSE +201 -67
- package/dist/storage/MongoBucketStorage.d.ts +1 -0
- package/dist/storage/MongoBucketStorage.js +3 -0
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +60 -39
- package/dist/storage/implementation/MongoBucketBatch.js +123 -296
- package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
- package/dist/storage/implementation/MongoOpIdAllocator.d.ts +6 -1
- package/dist/storage/implementation/MongoOpIdAllocator.js +12 -5
- package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -1
- package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +7 -0
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js +28 -2
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
- package/dist/storage/implementation/MongoReplicationOptions.d.ts +27 -0
- package/dist/storage/implementation/MongoReplicationOptions.js +26 -0
- package/dist/storage/implementation/MongoReplicationOptions.js.map +1 -0
- package/dist/storage/implementation/MongoSyncBucketStorage.js +2 -3
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoSyncRulesLock.d.ts +15 -2
- package/dist/storage/implementation/MongoSyncRulesLock.js +36 -14
- package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
- package/dist/storage/implementation/common/BatchOperationBuffer.d.ts +79 -0
- package/dist/storage/implementation/common/BatchOperationBuffer.js +286 -0
- package/dist/storage/implementation/common/BatchOperationBuffer.js.map +1 -0
- package/dist/storage/implementation/common/PersistedBatch.d.ts +10 -103
- package/dist/storage/implementation/common/PersistedBatch.js +1 -291
- package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
- package/dist/storage/implementation/common/PreparedPublication.d.ts +43 -0
- package/dist/storage/implementation/common/PreparedPublication.js +2 -0
- package/dist/storage/implementation/common/PreparedPublication.js.map +1 -0
- package/dist/storage/implementation/common/SourceRecordStore.d.ts +0 -2
- package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +17 -13
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js +259 -18
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +21 -15
- package/dist/storage/implementation/v1/PersistedBatchV1.js +58 -24
- package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/SourceRecordStoreV1.d.ts +0 -2
- package/dist/storage/implementation/v1/SourceRecordStoreV1.js +0 -3
- package/dist/storage/implementation/v1/SourceRecordStoreV1.js.map +1 -1
- package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +25 -14
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js +350 -23
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +1 -0
- package/dist/storage/implementation/v3/MongoCompactorV3.js +10 -4
- package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +39 -28
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
- package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +46 -24
- package/dist/storage/implementation/v3/PersistedBatchV3.js +159 -83
- package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +12 -0
- package/dist/storage/implementation/v3/SourceRecordStoreV3.js +41 -13
- package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +4 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +4 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +27 -2
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +61 -5
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
- package/package.json +9 -10
- package/src/storage/MongoBucketStorage.ts +4 -0
- package/src/storage/implementation/MongoBucketBatch.ts +185 -392
- package/src/storage/implementation/MongoOpIdAllocator.ts +12 -5
- package/src/storage/implementation/MongoParsedSyncConfigSet.ts +40 -1
- package/src/storage/implementation/MongoReplicationOptions.ts +28 -0
- package/src/storage/implementation/MongoSyncBucketStorage.ts +2 -3
- package/src/storage/implementation/MongoSyncRulesLock.ts +39 -17
- package/src/storage/implementation/common/BatchOperationBuffer.ts +353 -0
- package/src/storage/implementation/common/PersistedBatch.ts +12 -390
- package/src/storage/implementation/common/PreparedPublication.ts +49 -0
- package/src/storage/implementation/common/SourceRecordStore.ts +0 -2
- package/src/storage/implementation/v1/MongoBucketBatchV1.ts +339 -27
- package/src/storage/implementation/v1/PersistedBatchV1.ts +74 -32
- package/src/storage/implementation/v1/SourceRecordStoreV1.ts +0 -5
- package/src/storage/implementation/v3/MongoBucketBatchV3.ts +430 -32
- package/src/storage/implementation/v3/MongoCompactorV3.ts +13 -4
- package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +43 -29
- package/src/storage/implementation/v3/PersistedBatchV3.ts +209 -108
- package/src/storage/implementation/v3/SourceRecordStoreV3.ts +45 -15
- package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +9 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +79 -5
- package/test/src/checkpoint_cleanup.test.ts +75 -0
- package/test/src/concurrent_writers.test.ts +127 -53
- package/test/src/helpers/PublicationCommandMonitor.ts +119 -0
- package/test/src/helpers/concurrentWriter.mjs +2 -2
- package/test/src/invalid_sync_config.test.ts +9 -0
- package/test/src/object_storage_lifecycle.test.ts +72 -2
- package/test/src/prepared_publication.test.ts +388 -0
- package/test/src/replication_preparation.test.ts +829 -0
- package/test/src/replication_publication_baseline.test.ts +203 -0
- package/test/src/replication_publication_batching.test.ts +212 -0
- package/test/src/replication_uploads.test.ts +393 -0
- package/test/src/source_table_config.test.ts +4 -4
- package/test/src/storage_object_storage_inline_threshold.test.ts +1 -1
- package/test/src/storage_s3_compaction_lifecycle.test.ts +93 -1
- package/test/src/storage_sync.test.ts +83 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -2,6 +2,7 @@ import { mongo } from '@powersync/lib-service-mongodb';
|
|
|
2
2
|
import { ReplicationAssertionError } from '@powersync/lib-services-framework';
|
|
3
3
|
import { VersionedPowerSyncMongo } from './db.js';
|
|
4
4
|
import { MongoIdSequence, OpIdRange } from './MongoIdSequence.js';
|
|
5
|
+
import { REPLICATION_WRITE_OPTIONS } from './MongoReplicationOptions.js';
|
|
5
6
|
|
|
6
7
|
const RESERVATION_SIZE = 65_536n;
|
|
7
8
|
const LOW_WATER_MARK = 16_384n;
|
|
@@ -23,13 +24,19 @@ export class MongoOpIdAllocator {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
sequence(persistedOp: bigint): MongoIdSequence {
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
27
|
+
// Transactional writers pass the fenced stream head. Prepared v3/v4 writers
|
|
28
|
+
// pass their last ID within this publication (initially zero). Their per-lease
|
|
29
|
+
// ranges are globally disjoint, and assigned IDs are consumed before publish.
|
|
30
|
+
// Publication asserts that its final ID advances the fenced persisted head.
|
|
29
31
|
return new MongoIdSequence(persistedOp, this.ranges.slice());
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Remove IDs assigned to sealed work, even if publication is later abandoned.
|
|
36
|
+
* This only consumes allocation capacity; it does not advance durable progress.
|
|
37
|
+
* Keep the unused tail available to the next publication under this lease.
|
|
38
|
+
*/
|
|
39
|
+
consume(lastOp: bigint) {
|
|
33
40
|
this.ranges = this.ranges
|
|
34
41
|
.filter((range) => range.end > lastOp)
|
|
35
42
|
.map((range) => ({ start: range.start > lastOp ? range.start : lastOp + 1n, end: range.end }));
|
|
@@ -86,7 +93,7 @@ export class MongoOpIdAllocator {
|
|
|
86
93
|
}
|
|
87
94
|
}
|
|
88
95
|
],
|
|
89
|
-
{ upsert: true, returnDocument: 'before',
|
|
96
|
+
{ upsert: true, returnDocument: 'before', ...REPLICATION_WRITE_OPTIONS }
|
|
90
97
|
);
|
|
91
98
|
const last = previous?.op_id ?? 0n;
|
|
92
99
|
if (last > MAX_OP_ID - RESERVATION_SIZE) {
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import * as sqlite from 'node:sqlite';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ReplicationAbortedError,
|
|
5
|
+
ReplicationAssertionError,
|
|
6
|
+
ServiceAssertionError
|
|
7
|
+
} from '@powersync/lib-services-framework';
|
|
4
8
|
import {
|
|
5
9
|
BucketDefinitionMapping,
|
|
6
10
|
MultiSyncConfigBucketDefinitionMapping,
|
|
@@ -26,6 +30,41 @@ import { StorageConfig } from './models.js';
|
|
|
26
30
|
import { MongoHydrationState } from './MongoHydrationState.js';
|
|
27
31
|
|
|
28
32
|
export class MongoParsedSyncConfigSet implements storage.ParsedSyncConfigSet {
|
|
33
|
+
/** Config definitions/mappings are immutable; only their embedded lifecycle state changes. */
|
|
34
|
+
assertPublicationConfigs(states: readonly { _id: { toHexString(): string }; state: storage.SyncRuleState }[]): void {
|
|
35
|
+
const configured = new Set(this.#configsWithMapping.map((config) => config.syncConfigId));
|
|
36
|
+
const live = new Set(
|
|
37
|
+
states
|
|
38
|
+
.filter((state) =>
|
|
39
|
+
[storage.SyncRuleState.ACTIVE, storage.SyncRuleState.PROCESSING, storage.SyncRuleState.ERRORED].includes(
|
|
40
|
+
state.state
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
.map((state) => state._id.toHexString())
|
|
44
|
+
);
|
|
45
|
+
// Snapshot/metadata writers may intentionally select only one processing
|
|
46
|
+
// config while another active config serves clients. Full-stream checkpoint
|
|
47
|
+
// writers separately require every replicating config in their checkpoint guard.
|
|
48
|
+
if (![...live].some((id) => configured.has(id))) {
|
|
49
|
+
throw new ReplicationAbortedError('Replication writer must reload the selected sync configs');
|
|
50
|
+
}
|
|
51
|
+
const mappings = this.#configsWithMapping
|
|
52
|
+
.filter((config) => live.has(config.syncConfigId!))
|
|
53
|
+
.map((config) => config.mapping!);
|
|
54
|
+
// A stopped selected config is harmless only when all its persisted outputs
|
|
55
|
+
// are still owned by live selected configs. Cleanup may remove every other id.
|
|
56
|
+
const activeBuckets = new Set(mappings.flatMap((mapping) => mapping.allBucketDefinitionIds()));
|
|
57
|
+
const activeParameters = new Set(mappings.flatMap((mapping) => mapping.allParameterIndexIds()));
|
|
58
|
+
const activeEvents = new Set(mappings.flatMap((mapping) => mapping.allEventDefinitionIds()));
|
|
59
|
+
if (
|
|
60
|
+
this.mapping.allBucketDefinitionIds().some((id) => !activeBuckets.has(id)) ||
|
|
61
|
+
this.mapping.allParameterIndexIds().some((id) => !activeParameters.has(id)) ||
|
|
62
|
+
this.mapping.allEventDefinitionIds().some((id) => !activeEvents.has(id))
|
|
63
|
+
) {
|
|
64
|
+
throw new ReplicationAbortedError('Replication writer mappings are no longer live; reload the sync configs');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
29
68
|
public readonly hydrationState: HydrationState;
|
|
30
69
|
public readonly syncConfigs: SyncConfigWithErrors[];
|
|
31
70
|
public readonly replicationStreamName: string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read options for replication preparation outside publication transactions.
|
|
5
|
+
* Preparation produces writes that publication replays without rereading their
|
|
6
|
+
* source records, so it must not depend on data that can later be rolled back.
|
|
7
|
+
* Majority reads, majority writes and the lease's shared causal session provide
|
|
8
|
+
* durable causal ordering. A causal session alone does not select majority read
|
|
9
|
+
* concern; the ordinary default is local. Primary is the usual routing default,
|
|
10
|
+
* made explicit here so preparation does not inherit a different preference.
|
|
11
|
+
* Set transaction read concern at transaction level, not through these options.
|
|
12
|
+
*/
|
|
13
|
+
export const REPLICATION_READ_OPTIONS = {
|
|
14
|
+
readConcern: 'majority',
|
|
15
|
+
readPreference: 'primary'
|
|
16
|
+
} as const satisfies Pick<mongo.CommandOperationOptions, 'readConcern' | 'readPreference'>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Acknowledge durable replication writes before using allocated IDs, reporting
|
|
20
|
+
* publication success or preparing subsequent work in the shared causal session.
|
|
21
|
+
* Apply at transaction level for publication, and at operation level for writes
|
|
22
|
+
* outside transactions (ID reservations, metadata and post-commit cleanup).
|
|
23
|
+
* Resume-position hints deliberately use w: 1 instead: losing a hint causes
|
|
24
|
+
* source replay, while row publication and checkpoints still require durability.
|
|
25
|
+
*/
|
|
26
|
+
export const REPLICATION_WRITE_OPTIONS = {
|
|
27
|
+
writeConcern: { w: 'majority' }
|
|
28
|
+
} as const satisfies Pick<mongo.TransactionOptions, 'writeConcern'>;
|
|
@@ -34,7 +34,6 @@ import {
|
|
|
34
34
|
MongoGetCheckpointChangesOptions,
|
|
35
35
|
MongoSyncBucketStorageCheckpoint
|
|
36
36
|
} from './common/MongoSyncBucketStorageCheckpoint.js';
|
|
37
|
-
import { DEFAULT_INLINE_THRESHOLD_BYTES } from './common/PersistedBatch.js';
|
|
38
37
|
import type { VersionedPowerSyncMongo } from './db.js';
|
|
39
38
|
import { StorageConfig } from './models.js';
|
|
40
39
|
import { MongoBucketBatchOptions } from './MongoBucketBatch.js';
|
|
@@ -46,6 +45,7 @@ import { MongoPersistedReplicationStream } from './MongoPersistedReplicationStre
|
|
|
46
45
|
import { MongoSyncRulesLock } from './MongoSyncRulesLock.js';
|
|
47
46
|
import { MongoCheckpointAPIOptions, MongoWriteCheckpointAPI } from './MongoWriteCheckpointAPI.js';
|
|
48
47
|
import { ObjectStorage } from './v3/object-storage/ObjectStorage.js';
|
|
48
|
+
import { DEFAULT_INLINE_THRESHOLD_BYTES } from './v3/PersistedBatchV3.js';
|
|
49
49
|
|
|
50
50
|
export interface MongoSyncBucketStorageOptions {
|
|
51
51
|
checksumOptions?: Omit<MongoChecksumOptions, 'storageConfig'>;
|
|
@@ -278,7 +278,6 @@ export abstract class MongoSyncBucketStorage
|
|
|
278
278
|
const replicationLock = this.requireReplicationLock();
|
|
279
279
|
return {
|
|
280
280
|
logger: options.logger ?? this.logger,
|
|
281
|
-
db: this.db,
|
|
282
281
|
parsedSyncConfig: this.getParsedSyncConfigSet(options),
|
|
283
282
|
replicationStreamId: this.replicationStreamId,
|
|
284
283
|
replicationStreamName: this.replicationStreamName,
|
|
@@ -464,7 +463,7 @@ export abstract class MongoSyncBucketStorage
|
|
|
464
463
|
}
|
|
465
464
|
|
|
466
465
|
const stream = mergeAsyncIterables(
|
|
467
|
-
[this.checkpointChangesStream(signal), this.checkpointTimeoutStream(signal)],
|
|
466
|
+
[(signal) => this.checkpointChangesStream(signal), (signal) => this.checkpointTimeoutStream(signal)],
|
|
468
467
|
signal
|
|
469
468
|
);
|
|
470
469
|
|
|
@@ -12,8 +12,15 @@ const LOCK_DURATION_MS = 60 * 1000;
|
|
|
12
12
|
* processes that replication stream at a time.
|
|
13
13
|
*/
|
|
14
14
|
export class MongoSyncRulesLock implements storage.ReplicationLock {
|
|
15
|
-
/**
|
|
15
|
+
/** Public writer operations hold this mutex through preparation, publication and cleanup. */
|
|
16
16
|
readonly writerMutex = new utils.Mutex();
|
|
17
|
+
/**
|
|
18
|
+
* All writers use this causal session sequentially under writerMutex. Reusing
|
|
19
|
+
* it carries earlier writes into later majority reads without copying clocks.
|
|
20
|
+
* Only lease teardown ends it; disposing one writer must not affect the rest.
|
|
21
|
+
* Lease acquisition and renewal use independent operations and never wait here.
|
|
22
|
+
*/
|
|
23
|
+
readonly session: mongo.ClientSession;
|
|
17
24
|
|
|
18
25
|
private readonly abort = new AbortController();
|
|
19
26
|
readonly signal = this.abort.signal;
|
|
@@ -71,37 +78,42 @@ export class MongoSyncRulesLock implements storage.ReplicationLock {
|
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
constructor(
|
|
74
|
-
|
|
81
|
+
public readonly db: VersionedPowerSyncMongo,
|
|
75
82
|
public sync_rules_id: number,
|
|
76
83
|
public readonly lock_id: string,
|
|
77
84
|
private logger: Logger
|
|
78
85
|
) {
|
|
86
|
+
this.session = db.client.startSession({ causalConsistency: true });
|
|
79
87
|
this.refreshInterval = setInterval(async () => {
|
|
80
88
|
try {
|
|
81
89
|
await this.refresh();
|
|
82
90
|
} catch (e) {
|
|
83
|
-
this.
|
|
91
|
+
this.invalidate(e);
|
|
84
92
|
this.logger.error('Failed to refresh lock', e);
|
|
85
|
-
clearInterval(this.refreshInterval);
|
|
86
93
|
}
|
|
87
94
|
}, 30_130);
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
async release(): Promise<void> {
|
|
91
|
-
this.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
this.invalidate(new Error('Replication lock released'));
|
|
99
|
+
try {
|
|
100
|
+
const result = await this.db.sync_rules.updateOne(
|
|
101
|
+
{
|
|
102
|
+
_id: this.sync_rules_id,
|
|
103
|
+
'lock.id': this.lock_id
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
$unset: { lock: 1 }
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
if (result.modifiedCount == 0) {
|
|
110
|
+
// Log and ignore
|
|
111
|
+
this.logger.warn(`Lock already released: ${this.sync_rules_id}/${this.lock_id}`);
|
|
100
112
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
this.
|
|
113
|
+
} finally {
|
|
114
|
+
// Let an active operation finish/abort before ending its session. Queued
|
|
115
|
+
// writers check the aborted lease after acquiring the mutex and do no I/O.
|
|
116
|
+
await this.writerMutex.exclusiveLock(() => this.session.endSession());
|
|
105
117
|
}
|
|
106
118
|
}
|
|
107
119
|
|
|
@@ -109,6 +121,16 @@ export class MongoSyncRulesLock implements storage.ReplicationLock {
|
|
|
109
121
|
this.signal.throwIfAborted();
|
|
110
122
|
}
|
|
111
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Stop all local writers, including queued operations, when continuing this
|
|
126
|
+
* replication attempt would be unsafe. The job still releases the database
|
|
127
|
+
* lease during teardown; otherwise it expires after renewal stops.
|
|
128
|
+
*/
|
|
129
|
+
invalidate(reason: unknown): void {
|
|
130
|
+
this.abort.abort(reason);
|
|
131
|
+
clearInterval(this.refreshInterval);
|
|
132
|
+
}
|
|
133
|
+
|
|
112
134
|
static ownerFilter(streamId: number, lock: MongoSyncRulesLock) {
|
|
113
135
|
lock.throwIfAborted();
|
|
114
136
|
return { _id: streamId, 'lock.id': lock.lock_id };
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { logger as defaultLogger, Logger } from '@powersync/lib-services-framework';
|
|
2
|
+
import { InternalOpId, storage, utils } from '@powersync/service-core';
|
|
3
|
+
import { JSONBig } from '@powersync/service-jsonbig';
|
|
4
|
+
import { BucketDataSource, BucketDefinitionId } from '@powersync/service-sync-rules';
|
|
5
|
+
import { mongoTableId, replicaIdToSubkey } from '../../../utils/util.js';
|
|
6
|
+
import { currentBucketKey, MAX_ROW_SIZE } from '../MongoBucketBatchShared.js';
|
|
7
|
+
import { TaggedBucketParameterDocument } from '../models.js';
|
|
8
|
+
import { BucketDataDoc, BucketKey } from './BucketDataDoc.js';
|
|
9
|
+
import { BucketStateUpdate, SaveBucketDataOptions } from './PersistedBatch.js';
|
|
10
|
+
import { PublicationStats } from './PreparedPublication.js';
|
|
11
|
+
import { SourceRecordBucketState } from './SourceRecordStore.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Maximum size of operations we write in a single transaction.
|
|
15
|
+
*
|
|
16
|
+
* It's tricky to find the exact limit, but from experience, over 100MB
|
|
17
|
+
* can cause an error:
|
|
18
|
+
* > transaction is too large and will not fit in the storage engine cache
|
|
19
|
+
*
|
|
20
|
+
* Additionally, unbounded size here can balloon our memory usage in some edge
|
|
21
|
+
* cases.
|
|
22
|
+
*
|
|
23
|
+
* When we reach this threshold, we commit the transaction and start a new one.
|
|
24
|
+
*/
|
|
25
|
+
const MAX_TRANSACTION_BATCH_SIZE = 30_000_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Limit number of documents to write in a single transaction.
|
|
29
|
+
*
|
|
30
|
+
* This has an effect on error message size in some cases.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_TRANSACTION_DOC_COUNT = 2_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Shared operation generation, size accounting and diagnostics. Each storage
|
|
36
|
+
* version owns this buffer and decides when to write, retain or clear its data.
|
|
37
|
+
* No transaction, sealing or payload-preparation state lives here.
|
|
38
|
+
*/
|
|
39
|
+
export class BatchOperationBuffer {
|
|
40
|
+
bucketData: BucketDataDoc[] = [];
|
|
41
|
+
bucketParameters: TaggedBucketParameterDocument[] = [];
|
|
42
|
+
bucketStates = new Map<string, BucketStateUpdate>();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* For debug logging only.
|
|
46
|
+
*/
|
|
47
|
+
debugLastOpId: InternalOpId | null = null;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Very rough estimate of transaction size.
|
|
51
|
+
*/
|
|
52
|
+
currentSize: number;
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private readonly group_id: number,
|
|
56
|
+
writtenSize: number,
|
|
57
|
+
private readonly definitions: {
|
|
58
|
+
checkDefinitionId(id: BucketDefinitionId | null): BucketDefinitionId;
|
|
59
|
+
getBucketDefinitionId(source: BucketDataSource): BucketDefinitionId;
|
|
60
|
+
},
|
|
61
|
+
private readonly logger: Logger = defaultLogger
|
|
62
|
+
) {
|
|
63
|
+
this.currentSize = writtenSize;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
saveBucketData(options: SaveBucketDataOptions) {
|
|
67
|
+
const remaining_buckets = new Map<string, SourceRecordBucketState>();
|
|
68
|
+
for (let bucket of options.before_buckets) {
|
|
69
|
+
const mapped: SourceRecordBucketState = {
|
|
70
|
+
bucket: bucket.bucket,
|
|
71
|
+
definitionId: this.definitions.checkDefinitionId(bucket.definitionId),
|
|
72
|
+
id: bucket.id,
|
|
73
|
+
table: bucket.table
|
|
74
|
+
};
|
|
75
|
+
if (options.table.bucketDataSourceIds != null && !options.table.bucketDataSourceIds.has(mapped.definitionId!)) {
|
|
76
|
+
// The bucket definition is not active anymore.
|
|
77
|
+
// We don't cleanup these references upfront, but do need to ignore them after the definition is removed.
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
remaining_buckets.set(currentBucketKey(mapped), mapped);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const subkey = this.persistedSubkey(options.table.id, options.sourceKey);
|
|
84
|
+
const dchecksum = BigInt(utils.hashDelete(subkey));
|
|
85
|
+
|
|
86
|
+
for (const evaluated of options.evaluated) {
|
|
87
|
+
const definitionId = this.definitions.getBucketDefinitionId(evaluated.source);
|
|
88
|
+
const key = currentBucketKey({
|
|
89
|
+
definitionId: definitionId,
|
|
90
|
+
bucket: evaluated.bucket,
|
|
91
|
+
table: evaluated.table,
|
|
92
|
+
id: evaluated.id
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const recordData = JSONBig.stringify(evaluated.data);
|
|
96
|
+
const checksum = utils.hashData(evaluated.table, evaluated.id, recordData);
|
|
97
|
+
if (recordData.length > MAX_ROW_SIZE) {
|
|
98
|
+
const message = `Row ${key} too large: ${recordData.length} bytes. Removing.`;
|
|
99
|
+
this.logger.error(message);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
remaining_buckets.delete(key);
|
|
104
|
+
const byteEstimate = recordData.length + 200;
|
|
105
|
+
this.currentSize += byteEstimate;
|
|
106
|
+
|
|
107
|
+
const op_id = options.op_seq.next();
|
|
108
|
+
this.debugLastOpId = op_id;
|
|
109
|
+
|
|
110
|
+
this.addBucketDataPut({
|
|
111
|
+
bucketKey: {
|
|
112
|
+
bucket: evaluated.bucket,
|
|
113
|
+
definitionId: definitionId,
|
|
114
|
+
replicationStreamId: this.group_id
|
|
115
|
+
},
|
|
116
|
+
op_id,
|
|
117
|
+
bucket: evaluated.bucket,
|
|
118
|
+
sourceTableId: options.table.id,
|
|
119
|
+
sourceKey: options.sourceKey,
|
|
120
|
+
subkey,
|
|
121
|
+
table: evaluated.table,
|
|
122
|
+
rowId: evaluated.id,
|
|
123
|
+
checksum: BigInt(checksum),
|
|
124
|
+
data: recordData
|
|
125
|
+
});
|
|
126
|
+
this.incrementBucket(definitionId, evaluated.bucket, op_id, byteEstimate);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (let bucket of remaining_buckets.values()) {
|
|
130
|
+
const definitionId = bucket.definitionId!;
|
|
131
|
+
const op_id = options.op_seq.next();
|
|
132
|
+
this.debugLastOpId = op_id;
|
|
133
|
+
|
|
134
|
+
this.addBucketDataRemove({
|
|
135
|
+
bucketKey: {
|
|
136
|
+
replicationStreamId: this.group_id,
|
|
137
|
+
definitionId,
|
|
138
|
+
bucket: bucket.bucket
|
|
139
|
+
},
|
|
140
|
+
op_id,
|
|
141
|
+
sourceTableId: options.table.id,
|
|
142
|
+
sourceKey: options.sourceKey,
|
|
143
|
+
subkey,
|
|
144
|
+
table: bucket.table,
|
|
145
|
+
rowId: bucket.id,
|
|
146
|
+
checksum: dchecksum
|
|
147
|
+
});
|
|
148
|
+
this.currentSize += 200;
|
|
149
|
+
this.incrementBucket(definitionId, bucket.bucket, op_id, 200);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* New bucket operations persist protocol subkeys during replication. Older
|
|
155
|
+
* V1 documents may not have one, so sync reads retain a fallback.
|
|
156
|
+
*/
|
|
157
|
+
persistedSubkey(table: storage.SourceTableId, key: storage.ReplicaId): string {
|
|
158
|
+
return replicaIdToSubkey(table, key);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get bucketDataCount(): number {
|
|
162
|
+
return this.bucketData.length;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
incrementBucket(definitionId: BucketDefinitionId, bucket: string, op_id: InternalOpId, bytes: number) {
|
|
166
|
+
const key = `${definitionId ?? ''}:${bucket}`;
|
|
167
|
+
let existingState = this.bucketStates.get(key);
|
|
168
|
+
if (existingState) {
|
|
169
|
+
existingState.lastOp = op_id;
|
|
170
|
+
existingState.incrementCount += 1;
|
|
171
|
+
existingState.incrementBytes += bytes;
|
|
172
|
+
} else {
|
|
173
|
+
this.bucketStates.set(key, {
|
|
174
|
+
definitionId,
|
|
175
|
+
bucket,
|
|
176
|
+
lastOp: op_id,
|
|
177
|
+
incrementCount: 1,
|
|
178
|
+
incrementBytes: bytes,
|
|
179
|
+
incrementChunks: 0
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* V3 persists operations in chunks. Keep this separate from incrementBucket:
|
|
186
|
+
* operation counts are known while evaluating rows, while chunk counts and
|
|
187
|
+
* exact persisted bytes are only known after the writer has chunked a flush.
|
|
188
|
+
*/
|
|
189
|
+
incrementBucketPersistedChunk(definitionId: BucketDefinitionId, bucket: string, bytes: number) {
|
|
190
|
+
const key = `${definitionId ?? ''}:${bucket}`;
|
|
191
|
+
const existingState = this.bucketStates.get(key);
|
|
192
|
+
if (existingState != null) {
|
|
193
|
+
existingState.incrementChunks += 1;
|
|
194
|
+
existingState.incrementBytes += bytes;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* V3's compactor calculates byte deltas from persisted chunk metadata.
|
|
200
|
+
* Replace the writer's per-operation estimate with those exact sizes before
|
|
201
|
+
* flushing the corresponding bucket state.
|
|
202
|
+
*/
|
|
203
|
+
resetBucketPersistedBytes(definitionId: BucketDefinitionId, bucket: string) {
|
|
204
|
+
const state = this.bucketStates.get(`${definitionId ?? ''}:${bucket}`);
|
|
205
|
+
if (state != null) {
|
|
206
|
+
state.incrementBytes = 0;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
addBucketDataPut(options: {
|
|
211
|
+
op_id: InternalOpId;
|
|
212
|
+
bucketKey: BucketKey;
|
|
213
|
+
bucket: string;
|
|
214
|
+
sourceTableId: storage.SourceTable['id'];
|
|
215
|
+
sourceKey: storage.ReplicaId;
|
|
216
|
+
subkey: string;
|
|
217
|
+
table: string;
|
|
218
|
+
rowId: string;
|
|
219
|
+
checksum: bigint;
|
|
220
|
+
data: string;
|
|
221
|
+
}) {
|
|
222
|
+
this.bucketData.push({
|
|
223
|
+
bucketKey: options.bucketKey,
|
|
224
|
+
o: options.op_id,
|
|
225
|
+
op: 'PUT',
|
|
226
|
+
source_table: mongoTableId(options.sourceTableId),
|
|
227
|
+
source_key: options.sourceKey,
|
|
228
|
+
subkey: options.subkey,
|
|
229
|
+
table: options.table,
|
|
230
|
+
row_id: options.rowId,
|
|
231
|
+
checksum: options.checksum,
|
|
232
|
+
data: options.data
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
addBucketDataRemove(options: {
|
|
237
|
+
op_id: InternalOpId;
|
|
238
|
+
bucketKey: BucketKey;
|
|
239
|
+
sourceTableId: storage.SourceTable['id'];
|
|
240
|
+
sourceKey: storage.ReplicaId;
|
|
241
|
+
subkey: string;
|
|
242
|
+
table: string;
|
|
243
|
+
rowId: string;
|
|
244
|
+
checksum: bigint;
|
|
245
|
+
}) {
|
|
246
|
+
this.bucketData.push({
|
|
247
|
+
bucketKey: options.bucketKey,
|
|
248
|
+
o: options.op_id,
|
|
249
|
+
op: 'REMOVE',
|
|
250
|
+
source_table: mongoTableId(options.sourceTableId),
|
|
251
|
+
source_key: options.sourceKey,
|
|
252
|
+
subkey: options.subkey,
|
|
253
|
+
table: options.table,
|
|
254
|
+
row_id: options.rowId,
|
|
255
|
+
checksum: options.checksum,
|
|
256
|
+
data: null
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
shouldFlushTransaction(currentDataCount: number) {
|
|
261
|
+
return (
|
|
262
|
+
this.currentSize >= MAX_TRANSACTION_BATCH_SIZE ||
|
|
263
|
+
this.bucketDataCount >= MAX_TRANSACTION_DOC_COUNT ||
|
|
264
|
+
currentDataCount >= MAX_TRANSACTION_DOC_COUNT ||
|
|
265
|
+
this.bucketParameters.length >= MAX_TRANSACTION_DOC_COUNT
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Adopt one fully evaluated row; unfinished rows never mutate this buffer. */
|
|
270
|
+
append(row: BatchOperationBuffer): void {
|
|
271
|
+
// A single high-fanout row may exceed a normal batch target. Avoid the
|
|
272
|
+
// JavaScript argument-count limit while retaining its operation order.
|
|
273
|
+
for (const operation of row.bucketData) {
|
|
274
|
+
this.bucketData.push(operation);
|
|
275
|
+
}
|
|
276
|
+
for (const parameter of row.bucketParameters) {
|
|
277
|
+
this.bucketParameters.push(parameter);
|
|
278
|
+
}
|
|
279
|
+
for (const [key, state] of row.bucketStates) {
|
|
280
|
+
const previous = this.bucketStates.get(key);
|
|
281
|
+
this.bucketStates.set(key, {
|
|
282
|
+
...state,
|
|
283
|
+
incrementCount: (previous?.incrementCount ?? 0) + state.incrementCount,
|
|
284
|
+
incrementBytes: (previous?.incrementBytes ?? 0) + state.incrementBytes,
|
|
285
|
+
incrementChunks: (previous?.incrementChunks ?? 0) + state.incrementChunks
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
this.currentSize += row.currentSize;
|
|
289
|
+
this.debugLastOpId = row.debugLastOpId ?? this.debugLastOpId;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
clear(): void {
|
|
293
|
+
this.bucketData = [];
|
|
294
|
+
this.bucketParameters = [];
|
|
295
|
+
this.bucketStates.clear();
|
|
296
|
+
this.currentSize = 0;
|
|
297
|
+
this.debugLastOpId = null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
logFlush(startAt: number, currentDataCount: number, options?: storage.BucketBatchCommitOptions): PublicationStats {
|
|
301
|
+
const flushedSomething =
|
|
302
|
+
this.bucketDataCount > 0 ||
|
|
303
|
+
this.bucketParameters.length > 0 ||
|
|
304
|
+
currentDataCount > 0 ||
|
|
305
|
+
this.bucketStates.size > 0;
|
|
306
|
+
if (flushedSomething) {
|
|
307
|
+
const duration = Math.round(performance.now() - startAt);
|
|
308
|
+
if (options?.oldestUncommittedChange != null) {
|
|
309
|
+
const replicationLag = Math.round((Date.now() - options.oldestUncommittedChange.getTime()) / 1000);
|
|
310
|
+
|
|
311
|
+
this.logger.info(
|
|
312
|
+
`Flushed: ${this.bucketDataCount} ops, ${this.bucketParameters.length} index entries, ${
|
|
313
|
+
currentDataCount
|
|
314
|
+
} records. ${Math.round(this.currentSize / 1024)}kb in ${duration}ms. Last op_id: ${this.debugLastOpId}. Replication lag: ${replicationLag}s`,
|
|
315
|
+
{
|
|
316
|
+
flushed: {
|
|
317
|
+
duration: duration,
|
|
318
|
+
size: this.currentSize,
|
|
319
|
+
bucket_ops_count: this.bucketDataCount,
|
|
320
|
+
parameter_indexes_count: this.bucketParameters.length,
|
|
321
|
+
source_records_count: currentDataCount,
|
|
322
|
+
replication_lag_seconds: replicationLag
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
);
|
|
326
|
+
} else {
|
|
327
|
+
this.logger.info(
|
|
328
|
+
`Flushed: ${this.bucketDataCount} ops, ${this.bucketParameters.length} index entries, ${
|
|
329
|
+
currentDataCount
|
|
330
|
+
} records. ${Math.round(this.currentSize / 1024)}kb in ${duration}ms. Last op_id: ${this.debugLastOpId}`,
|
|
331
|
+
{
|
|
332
|
+
flushed: {
|
|
333
|
+
duration: duration,
|
|
334
|
+
size: this.currentSize,
|
|
335
|
+
bucket_ops_count: this.bucketDataCount,
|
|
336
|
+
parameter_indexes_count: this.bucketParameters.length,
|
|
337
|
+
source_records_count: currentDataCount
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const stats = {
|
|
345
|
+
bucketDataCount: this.bucketDataCount,
|
|
346
|
+
parameterDataCount: this.bucketParameters.length,
|
|
347
|
+
currentDataCount: currentDataCount,
|
|
348
|
+
flushedAny: flushedSomething
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
return stats;
|
|
352
|
+
}
|
|
353
|
+
}
|