@powersync/service-module-mongodb-storage 0.18.2 → 0.19.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.
Files changed (143) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
  3. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
  4. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
  5. package/dist/storage/MongoBucketStorage.d.ts +5 -1
  6. package/dist/storage/MongoBucketStorage.js +5 -1
  7. package/dist/storage/MongoBucketStorage.js.map +1 -1
  8. package/dist/storage/implementation/MongoBucketBatch.d.ts +3 -0
  9. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  10. package/dist/storage/implementation/MongoChecksums.d.ts +2 -0
  11. package/dist/storage/implementation/MongoChecksums.js +3 -2
  12. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  13. package/dist/storage/implementation/MongoCompactor.d.ts +15 -19
  14. package/dist/storage/implementation/MongoCompactor.js +68 -260
  15. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  16. package/dist/storage/implementation/MongoStorageProvider.js +26 -3
  17. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  18. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +12 -5
  19. package/dist/storage/implementation/MongoSyncBucketStorage.js +25 -14
  20. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  21. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +2 -1
  22. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +146 -34
  23. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  24. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +2 -0
  25. package/dist/storage/implementation/common/PersistedBatch.d.ts +6 -0
  26. package/dist/storage/implementation/common/PersistedBatch.js +15 -8
  27. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  28. package/dist/storage/implementation/models.d.ts +16 -0
  29. package/dist/storage/implementation/models.js.map +1 -1
  30. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +11 -2
  31. package/dist/storage/implementation/v1/MongoCompactorV1.js +250 -1
  32. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  33. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +20 -11
  34. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  35. package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
  36. package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
  37. package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
  38. package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
  39. package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
  40. package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
  41. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +3 -1
  42. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  43. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +1 -0
  44. package/dist/storage/implementation/v3/MongoChecksumsV3.js +59 -103
  45. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  46. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +23 -2
  47. package/dist/storage/implementation/v3/MongoCompactorV3.js +354 -219
  48. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  49. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +5 -0
  50. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +12 -11
  51. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  52. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +5 -3
  53. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +109 -106
  54. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  55. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +5 -2
  56. package/dist/storage/implementation/v3/PersistedBatchV3.js +53 -15
  57. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  58. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +2 -1
  59. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +3 -0
  60. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  61. package/dist/storage/implementation/v3/bucket-format.d.ts +10 -1
  62. package/dist/storage/implementation/v3/bucket-format.js +28 -9
  63. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  64. package/dist/storage/implementation/v3/chunking.js +3 -1
  65. package/dist/storage/implementation/v3/chunking.js.map +1 -1
  66. package/dist/storage/implementation/v3/models.d.ts +38 -1
  67. package/dist/storage/implementation/v3/models.js.map +1 -1
  68. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +21 -0
  69. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +44 -0
  70. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
  71. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +30 -0
  72. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
  73. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
  74. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +70 -0
  75. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +146 -0
  76. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
  77. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +42 -0
  78. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +285 -0
  79. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
  80. package/dist/types/types.d.ts +35 -0
  81. package/dist/types/types.js +35 -1
  82. package/dist/types/types.js.map +1 -1
  83. package/dist/utils/test-utils.js +3 -1
  84. package/dist/utils/test-utils.js.map +1 -1
  85. package/dist/utils/util.d.ts +8 -1
  86. package/dist/utils/util.js +125 -2
  87. package/dist/utils/util.js.map +1 -1
  88. package/package.json +10 -7
  89. package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
  90. package/src/storage/MongoBucketStorage.ts +10 -2
  91. package/src/storage/implementation/MongoBucketBatch.ts +4 -0
  92. package/src/storage/implementation/MongoChecksums.ts +5 -2
  93. package/src/storage/implementation/MongoCompactor.ts +90 -314
  94. package/src/storage/implementation/MongoStorageProvider.ts +32 -3
  95. package/src/storage/implementation/MongoSyncBucketStorage.ts +34 -24
  96. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +164 -37
  97. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +2 -0
  98. package/src/storage/implementation/common/PersistedBatch.ts +22 -10
  99. package/src/storage/implementation/models.ts +16 -0
  100. package/src/storage/implementation/v1/MongoCompactorV1.ts +292 -8
  101. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +54 -40
  102. package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
  103. package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
  104. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +3 -1
  105. package/src/storage/implementation/v3/MongoChecksumsV3.ts +75 -105
  106. package/src/storage/implementation/v3/MongoCompactorV3.ts +456 -258
  107. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +21 -19
  108. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +123 -126
  109. package/src/storage/implementation/v3/PersistedBatchV3.ts +69 -16
  110. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +5 -0
  111. package/src/storage/implementation/v3/bucket-format.ts +36 -9
  112. package/src/storage/implementation/v3/chunking.ts +3 -1
  113. package/src/storage/implementation/v3/models.ts +40 -1
  114. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +54 -0
  115. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +29 -0
  116. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +171 -0
  117. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +343 -0
  118. package/src/types/types.ts +42 -1
  119. package/src/utils/test-utils.ts +3 -1
  120. package/src/utils/util.ts +182 -4
  121. package/test/src/__snapshots__/storage_sync.test.ts.snap +1 -583
  122. package/test/src/cleanup-stopped-sync-configs.test.ts +24 -1
  123. package/test/src/helpers/MemoryObjectStorage.ts +53 -0
  124. package/test/src/helpers/s3TestFactory.ts +69 -0
  125. package/test/src/object_storage_lifecycle.test.ts +59 -0
  126. package/test/src/setup.ts +6 -1
  127. package/test/src/storage.test.ts +216 -2
  128. package/test/src/storage_compacting.test.ts +176 -187
  129. package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
  130. package/test/src/storage_s3_checksums.test.ts +191 -0
  131. package/test/src/storage_s3_compaction_lifecycle.test.ts +417 -0
  132. package/test/src/storage_s3_reading.test.ts +526 -0
  133. package/test/src/storage_s3_writing.test.ts +135 -0
  134. package/test/src/storage_sync.test.ts +95 -5
  135. package/tsconfig.tsbuildinfo +1 -1
  136. package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
  137. package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
  138. package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
  139. package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
  140. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -84
  141. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
  142. package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
  143. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -108
@@ -1,20 +1,30 @@
1
+ import { ServiceAssertionError } from '@powersync/lib-services-framework';
2
+ import { bson, InternalOpId } from '@powersync/service-core';
1
3
  import { BucketDataDoc, BucketKey } from '../common/BucketDataDoc.js';
2
4
  import { BucketDataDocumentV3, BucketOperation } from './models.js';
3
5
 
4
- export function serializeBucketData(bucket: string, operations: BucketDataDoc[]): BucketDataDocumentV3 {
6
+ /**
7
+ * Serialize a non-empty list of operations in strictly ascending `o` order.
8
+ * Preserving that order establishes the {@link BucketDataDocumentV3} range
9
+ * invariants used by metadata-only queries.
10
+ */
11
+ export function serializeBucketData(
12
+ bucket: string,
13
+ operations: BucketDataDoc[],
14
+ options?: { targetOp?: InternalOpId | null }
15
+ ): BucketDataDocumentV3 {
5
16
  const minOp = operations[0].o;
6
17
  const maxOp = operations[operations.length - 1].o;
7
18
 
8
19
  let totalChecksum = 0n;
9
- let totalSize = 0;
10
- let maxTargetOp: bigint | null = null;
20
+ let maxTargetOp: InternalOpId | null = options?.targetOp ?? null;
21
+ let hasClearOp = false;
11
22
 
12
23
  const ops: BucketOperation[] = operations.map((op) => {
13
24
  totalChecksum += op.checksum;
14
- totalSize += op.data?.length ?? 0;
15
25
 
16
- if (op.target_op != null && (maxTargetOp == null || op.target_op > maxTargetOp)) {
17
- maxTargetOp = op.target_op;
26
+ if (op.op == 'CLEAR') {
27
+ hasClearOp = true;
18
28
  }
19
29
 
20
30
  return {
@@ -29,6 +39,8 @@ export function serializeBucketData(bucket: string, operations: BucketDataDoc[])
29
39
  };
30
40
  });
31
41
 
42
+ const size = bson.calculateObjectSize(ops);
43
+
32
44
  return {
33
45
  _id: {
34
46
  b: bucket,
@@ -37,8 +49,9 @@ export function serializeBucketData(bucket: string, operations: BucketDataDoc[])
37
49
  min_op: minOp,
38
50
  checksum: totalChecksum,
39
51
  count: operations.length,
40
- size: totalSize,
52
+ size,
41
53
  target_op: maxTargetOp,
54
+ ...(hasClearOp ? { has_clear_op: true as const } : {}),
42
55
  ops
43
56
  };
44
57
  }
@@ -48,6 +61,11 @@ export function* loadBucketDataDocument(
48
61
  doc: BucketDataDocumentV3
49
62
  ): Generator<BucketDataDoc> {
50
63
  const { _id, ops } = doc;
64
+ if (!ops) {
65
+ throw new ServiceAssertionError(
66
+ `Missing ops array on BucketDataDocumentV3 at _id.o=${_id.o}. Callers must patch doc.ops from S3 before calling this function.`
67
+ );
68
+ }
51
69
  const bucketKey = {
52
70
  ...context,
53
71
  bucket: _id.b
@@ -63,8 +81,17 @@ export function* loadBucketDataDocument(
63
81
  table: op.table,
64
82
  row_id: op.row_id,
65
83
  checksum: op.checksum,
66
- data: op.data,
67
- target_op: doc.target_op ?? null
84
+ data: op.data
68
85
  };
69
86
  }
70
87
  }
88
+
89
+ export function maxOpId(a: InternalOpId | null | undefined, b: InternalOpId | null | undefined): InternalOpId | null {
90
+ if (a == null) {
91
+ return b ?? null;
92
+ }
93
+ if (b == null) {
94
+ return a ?? null;
95
+ }
96
+ return a > b ? a : b;
97
+ }
@@ -1,3 +1,4 @@
1
+ import { bson } from '@powersync/service-core';
1
2
  import { BucketDataDoc } from '../common/BucketDataDoc.js';
2
3
 
3
4
  export const DEFAULT_MAX_DOC_SIZE_BYTES = 1024 * 1024; // 1MB
@@ -18,7 +19,8 @@ export function chunkBucketData(
18
19
  let currentSize = 0;
19
20
 
20
21
  for (const op of operations) {
21
- const opSize = op.data?.length ?? 0;
22
+ // Add 5 to account for array index up to 4 digits (9999) plus null byte.
23
+ const opSize = bson.calculateObjectSize(op) + 5;
22
24
 
23
25
  if (currentSize + opSize > maxDocSizeBytes && currentChunk.length > 0) {
24
26
  chunks.push(currentChunk);
@@ -176,14 +176,53 @@ export interface BucketOperation {
176
176
  data: string | null;
177
177
  }
178
178
 
179
+ export interface StorageRef {
180
+ path: string;
181
+ file_size: number;
182
+ }
183
+
184
+ /** An S3 object that may be deleted once its grace period has elapsed. */
185
+ export interface ObjectStorageDeletionMarker {
186
+ _id: bson.ObjectId;
187
+ path: string;
188
+ delete_after: Date;
189
+ }
190
+
191
+ /**
192
+ * A non-empty chunk of a bucket's ordered operation stream.
193
+ *
194
+ * All writers maintain these invariants:
195
+ *
196
+ * - `ops` is non-empty and strictly ordered by `o`.
197
+ * - `min_op` is `ops[0].o` and `_id.o` is `ops[ops.length - 1].o`.
198
+ * - Documents for the same bucket have ordered, disjoint operation ranges,
199
+ * although gaps between ranges are allowed.
200
+ * - `checksum` is the sum of every operation checksum and `count` is
201
+ * `ops.length`.
202
+ *
203
+ * Metadata-only checksum and data queries rely on these invariants to identify
204
+ * boundary-straddling documents without reading `ops`.
205
+ */
179
206
  export interface BucketDataDocumentV3 {
180
207
  _id: BucketDataKey;
181
208
  min_op: bigint;
182
209
  checksum: bigint;
183
210
  count: number;
184
211
  size: number;
212
+ /**
213
+ * The greatest operation boundary that influenced this document's contents.
214
+ * Bucket-data reads propagate it so serving an earlier checkpoint can be
215
+ * invalidated when compaction changed the operations being returned.
216
+ */
185
217
  target_op?: bigint | null;
186
- ops: BucketOperation[];
218
+ ops?: BucketOperation[];
219
+ storage_ref?: StorageRef;
220
+ /**
221
+ * Present (and always true) when this document contains a CLEAR operation.
222
+ * In that case, this is the first document in the bucket: all preceding
223
+ * documents have been removed.
224
+ */
225
+ has_clear_op?: true;
187
226
  }
188
227
 
189
228
  export function serializeParameterLookup(lookup: ScopedParameterLookup): bson.Binary {
@@ -0,0 +1,54 @@
1
+ import { storage } from '@powersync/service-core';
2
+ import * as bson from 'bson';
3
+ import { BucketDataDocumentV3, BucketOperation } from '../models.js';
4
+ import { ObjectStorage } from './ObjectStorage.js';
5
+
6
+ export class BucketDataObjectStorage {
7
+ constructor(private readonly storage: ObjectStorage) {}
8
+
9
+ async store(path: string, ops: BucketOperation[]): Promise<{ fileSize: number }> {
10
+ const bsonBuffer = bson.serialize({ ops });
11
+ await this.storage.put(path, bsonBuffer, { contentType: 'application/bson', contentEncoding: null });
12
+ return { fileSize: bsonBuffer.byteLength };
13
+ }
14
+
15
+ async retrieve(path: string, options: { signal?: AbortSignal }): Promise<BucketOperation[]> {
16
+ const { data, metadata } = await this.storage.get(path, { signal: options.signal });
17
+ if (metadata.contentEncoding != null) {
18
+ throw new Error(`Unexpected content encoding: ${metadata.contentEncoding}`);
19
+ }
20
+ if (metadata.contentType !== 'application/bson') {
21
+ throw new Error(`Unexpected content type: ${metadata.contentType}`);
22
+ }
23
+ const wrapper = bson.deserialize(data, storage.BSON_DESERIALIZE_INTERNAL_OPTIONS);
24
+ return wrapper.ops;
25
+ }
26
+
27
+ async delete(paths: string[]): Promise<void> {
28
+ return this.storage.delete(paths);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Load offloaded operations and patch them onto their MongoDB metadata documents.
34
+ * The object-storage implementation is responsible for limiting request
35
+ * concurrency across all callers.
36
+ */
37
+ export async function hydrateBucketDataDocuments(
38
+ documents: BucketDataDocumentV3[],
39
+ objectStorage: ObjectStorage | undefined,
40
+ options: { signal?: AbortSignal }
41
+ ): Promise<void> {
42
+ if (!objectStorage) {
43
+ return;
44
+ }
45
+
46
+ options.signal?.throwIfAborted();
47
+ const store = new BucketDataObjectStorage(objectStorage);
48
+ const storedDocuments = documents.filter((document) => document.storage_ref);
49
+ await Promise.all(
50
+ storedDocuments.map(async (document) => {
51
+ document.ops = await store.retrieve(document.storage_ref!.path, { signal: options.signal });
52
+ })
53
+ );
54
+ }
@@ -0,0 +1,29 @@
1
+ export interface ObjectStoragePutMetadata {
2
+ contentType: string;
3
+ contentEncoding: string | null;
4
+ }
5
+
6
+ export class ObjectStorageError extends Error {
7
+ readonly retryable: boolean;
8
+
9
+ constructor(message: string, options: { cause: unknown; retryable: boolean }) {
10
+ super(message, { cause: options.cause });
11
+ this.name = 'ObjectStorageError';
12
+ this.retryable = options.retryable;
13
+ }
14
+ }
15
+
16
+ export function isRetryableObjectStorageError(error: unknown): error is ObjectStorageError {
17
+ return error instanceof ObjectStorageError && error.retryable;
18
+ }
19
+
20
+ export interface ObjectStorage {
21
+ put(path: string, data: Uint8Array, metadata: ObjectStoragePutMetadata): Promise<void>;
22
+ get(
23
+ path: string,
24
+ options?: { signal?: AbortSignal }
25
+ ): Promise<{ data: Uint8Array; metadata: ObjectStoragePutMetadata }>;
26
+ list(prefix: string, options?: { signal?: AbortSignal }): AsyncIterable<string>;
27
+ delete(paths: string[]): Promise<void>;
28
+ deletePrefix(prefix: string, options?: { signal?: AbortSignal }): Promise<{ objectCount: number }>;
29
+ }
@@ -0,0 +1,171 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { Logger } from '@powersync/lib-services-framework';
3
+ import * as bson from 'bson';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { ObjectStorageDeletionMarker } from '../models.js';
6
+ import { VersionedPowerSyncMongoV3 } from '../VersionedPowerSyncMongoV3.js';
7
+ import { BucketDataObjectStorage } from './BucketDataObjectStorage.js';
8
+ import { ObjectStorage } from './ObjectStorage.js';
9
+
10
+ export const OBJECT_STORAGE_UPLOAD_LEASE_MS = 60 * 60 * 1000;
11
+ export const OBJECT_STORAGE_PUBLICATION_SAFETY_MARGIN_MS = 2 * 60 * 1000;
12
+ export const OBJECT_STORAGE_REFERENCE_GRACE_MS = 15 * 60 * 1000;
13
+ /**
14
+ * Protect against arbitrary-long bucket names and parameters exceeding the S3 key length limits.
15
+ *
16
+ * Any bucket name over this length is hashed.
17
+ */
18
+ const MAX_READABLE_BUCKET_SEGMENT_BYTES = 128;
19
+
20
+ export interface PreparedObjectStorageUpload {
21
+ markerId: bson.ObjectId;
22
+ path: string;
23
+ deleteAfter: Date;
24
+ }
25
+
26
+ /**
27
+ * Coordinates the MongoDB outbox used to make object-storage changes recoverable.
28
+ *
29
+ * A MongoDB collection is used to track files that will be uploaded, or should be deleted.
30
+ * By using a MongoDB collection, we can use the same transaction that creates or removes
31
+ * references to these files, making the operations atomic.
32
+ *
33
+ * These markers indicate which files should be deleted - either by being orphaned during create,
34
+ * or after references to them are removed.
35
+ */
36
+ export class ObjectStorageLifecycle {
37
+ readonly bucketData: BucketDataObjectStorage;
38
+
39
+ constructor(
40
+ private readonly db: VersionedPowerSyncMongoV3,
41
+ private readonly replicationStreamId: number,
42
+ private readonly objectStorage: ObjectStorage
43
+ ) {
44
+ this.bucketData = new BucketDataObjectStorage(objectStorage);
45
+ }
46
+
47
+ streamPrefix(): string {
48
+ return `bucket-data/${this.replicationStreamId}/`;
49
+ }
50
+
51
+ definitionPrefix(definitionId: string): string {
52
+ return `${this.streamPrefix()}${definitionId}/`;
53
+ }
54
+
55
+ allocatePath(definitionId: string, bucket: string, minOp: bigint, maxOp: bigint): string {
56
+ // Preserve readable paths for normal buckets, but bound user-controlled
57
+ // parameter values so they cannot consume the S3 key-length budget.
58
+ const escapedBucket = encodeURIComponent(bucket);
59
+ const bucketSegment =
60
+ Buffer.byteLength(escapedBucket, 'utf8') <= MAX_READABLE_BUCKET_SEGMENT_BYTES
61
+ ? escapedBucket
62
+ : `sha256-${createHash('sha256').update(bucket).digest('base64url')}`;
63
+ return `${this.definitionPrefix(definitionId)}${bucketSegment}/${minOp}-${maxOp}-${randomUUID()}.bson`;
64
+ }
65
+
66
+ /**
67
+ * Indicate that files will be uploaded to S3.
68
+ *
69
+ * These markers are expected to be removed using publishUploads(). If that does not happen within
70
+ * the grace period, the files will be deleted.
71
+ */
72
+ async prepareUploads(paths: string[], now = new Date()): Promise<PreparedObjectStorageUpload[]> {
73
+ const deleteAfter = new Date(now.getTime() + OBJECT_STORAGE_UPLOAD_LEASE_MS);
74
+ const uploads = paths.map((path) => ({ markerId: new bson.ObjectId(), path, deleteAfter }));
75
+ if (uploads.length) {
76
+ await this.db.pendingObjectStorageDeletes(this.replicationStreamId).insertMany(
77
+ uploads.map((upload) => ({
78
+ _id: upload.markerId,
79
+ path: upload.path,
80
+ delete_after: upload.deleteAfter
81
+ }))
82
+ );
83
+ }
84
+ return uploads;
85
+ }
86
+
87
+ canPublish(upload: PreparedObjectStorageUpload, now = new Date()): boolean {
88
+ return now.getTime() < upload.deleteAfter.getTime() - OBJECT_STORAGE_PUBLICATION_SAFETY_MARGIN_MS;
89
+ }
90
+
91
+ /**
92
+ * Remove markers for files that have finished uploading, and are now referenced from other documents.
93
+ *
94
+ * Call this in the same transaction as the one creating the references.
95
+ */
96
+ async publishUploads(uploads: PreparedObjectStorageUpload[], session: mongo.ClientSession): Promise<void> {
97
+ for (const upload of uploads) {
98
+ if (!this.canPublish(upload)) {
99
+ throw new Error(`Object storage publication lease expired for ${upload.path}`);
100
+ }
101
+ }
102
+ if (uploads.length === 0) {
103
+ return;
104
+ }
105
+
106
+ const result = await this.db
107
+ .pendingObjectStorageDeletes(this.replicationStreamId)
108
+ .deleteMany({ _id: { $in: uploads.map((upload) => upload.markerId) } }, { session });
109
+ if (result.deletedCount !== uploads.length) {
110
+ throw new Error(`Missing object storage publication markers`);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Mark old paths for deletion.
116
+ *
117
+ * By marking for deletion instead of immediately deleting:
118
+ * 1. This avoids inconsistencies/orphaned files between MongoDB and S3, by allowing the markers to
119
+ * be persisted transactionally, while the deletes happen asynchronously and can be retried.
120
+ * 2. The deletes are delayed, giving clients a grace period to continue using the files for
121
+ * in-progress requests.
122
+ *
123
+ * Call this in the same transaction as the one removing the references to these files.
124
+ */
125
+ async retire(paths: Iterable<string>, session: mongo.ClientSession, now = new Date()): Promise<void> {
126
+ const markers: ObjectStorageDeletionMarker[] = Array.from(paths, (path) => ({
127
+ _id: new bson.ObjectId(),
128
+ path,
129
+ delete_after: new Date(now.getTime() + OBJECT_STORAGE_REFERENCE_GRACE_MS)
130
+ }));
131
+ if (markers.length) {
132
+ await this.db.pendingObjectStorageDeletes(this.replicationStreamId).insertMany(markers, { session });
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Discover and delete objects by their storage prefix
138
+ * without touching the database.
139
+ */
140
+ async deletePrefix(prefix: string, options?: { signal?: AbortSignal }): Promise<{ objectCount: number }> {
141
+ return this.objectStorage.deletePrefix(prefix, { signal: options?.signal });
142
+ }
143
+
144
+ async cleanup(logger: Logger): Promise<void> {
145
+ const markers = this.db.pendingObjectStorageDeletes(this.replicationStreamId);
146
+ const cursor = markers.find({ delete_after: { $lte: new Date() } });
147
+ let batch: ObjectStorageDeletionMarker[] = [];
148
+
149
+ const deleteBatch = async () => {
150
+ if (batch.length === 0) {
151
+ return;
152
+ }
153
+ const deleting = batch;
154
+ batch = [];
155
+ try {
156
+ await this.bucketData.delete(deleting.map((marker) => marker.path));
157
+ await markers.deleteMany({ _id: { $in: deleting.map((marker) => marker._id) } });
158
+ } catch (error) {
159
+ logger.warn('Failed to clean up object storage deletion markers; will retry during the next compaction', error);
160
+ }
161
+ };
162
+
163
+ for await (const marker of cursor) {
164
+ batch.push(marker);
165
+ if (batch.length === 500) {
166
+ await deleteBatch();
167
+ }
168
+ }
169
+ await deleteBatch();
170
+ }
171
+ }