@powersync/service-module-mongodb-storage 0.20.0 → 0.22.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 +62 -0
- package/dist/storage/MongoBucketStorage.d.ts +36 -3
- package/dist/storage/MongoBucketStorage.js +372 -207
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +17 -5
- package/dist/storage/implementation/MongoBucketBatch.js +44 -17
- package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
- package/dist/storage/implementation/MongoCompactor.d.ts +19 -79
- package/dist/storage/implementation/MongoCompactor.js +27 -238
- package/dist/storage/implementation/MongoCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoParameterCompactor.d.ts +83 -10
- package/dist/storage/implementation/MongoParameterCompactor.js +309 -96
- package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +9 -1
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js +29 -0
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +4 -2
- package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -1
- package/dist/storage/implementation/MongoStorageProvider.js +7 -3
- package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
- package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +27 -11
- package/dist/storage/implementation/MongoSyncBucketStorage.js +78 -31
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoSyncRulesLock.js +21 -9
- package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
- package/dist/storage/implementation/MongoWriteBatch.d.ts +49 -0
- package/dist/storage/implementation/MongoWriteBatch.js +149 -0
- package/dist/storage/implementation/MongoWriteBatch.js.map +1 -0
- package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -7
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js +9 -30
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
- package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
- package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
- package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
- package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
- package/dist/storage/implementation/common/PersistedBatch.d.ts +32 -4
- package/dist/storage/implementation/common/PersistedBatch.js +49 -6
- package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.d.ts +3 -0
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +3 -0
- package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
- package/dist/storage/implementation/db.d.ts +9 -1
- package/dist/storage/implementation/db.js +27 -1
- package/dist/storage/implementation/db.js.map +1 -1
- package/dist/storage/implementation/models.d.ts +31 -0
- package/dist/storage/implementation/models.js.map +1 -1
- package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +2 -0
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js +33 -4
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +38 -6
- package/dist/storage/implementation/v1/MongoCompactorV1.js +247 -20
- package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
- package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
- package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +65 -16
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
- package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +7 -5
- package/dist/storage/implementation/v1/PersistedBatchV1.js +22 -28
- package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/models.js +1 -0
- package/dist/storage/implementation/v1/models.js.map +1 -1
- package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
- package/dist/storage/implementation/v3/CompactionLease.js +131 -0
- package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
- package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +10 -2
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js +100 -26
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +82 -25
- package/dist/storage/implementation/v3/MongoCompactorV3.js +698 -159
- package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
- package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +91 -31
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
- package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
- package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
- package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +14 -8
- package/dist/storage/implementation/v3/PersistedBatchV3.js +76 -42
- package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
- package/dist/storage/implementation/v3/bucket-format.js +2 -0
- package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
- package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
- package/dist/storage/implementation/v3/compact-utils.js +216 -0
- package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
- package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
- package/dist/storage/implementation/v3/compaction-constants.js +6 -0
- package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
- package/dist/storage/implementation/v3/models.d.ts +84 -4
- package/dist/storage/implementation/v3/models.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +16 -5
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +6 -8
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +17 -11
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +38 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +164 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +197 -25
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
- package/dist/storage/implementation/v3/source-table-utils.d.ts +6 -6
- package/dist/storage/implementation/v3/source-table-utils.js +30 -37
- package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
- package/dist/storage/storage-index.d.ts +2 -1
- package/dist/storage/storage-index.js +1 -0
- package/dist/storage/storage-index.js.map +1 -1
- package/dist/types/types.d.ts +28 -1
- package/dist/types/types.js +28 -7
- package/dist/types/types.js.map +1 -1
- package/dist/utils/test-utils.js +1 -0
- package/dist/utils/test-utils.js.map +1 -1
- package/dist/utils/util.js +3 -1
- package/dist/utils/util.js.map +1 -1
- package/package.json +10 -9
- package/src/storage/MongoBucketStorage.ts +507 -269
- package/src/storage/implementation/MongoBucketBatch.ts +67 -24
- package/src/storage/implementation/MongoCompactor.ts +35 -346
- package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
- package/src/storage/implementation/MongoParsedSyncConfigSet.ts +34 -0
- package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +4 -2
- package/src/storage/implementation/MongoStorageProvider.ts +15 -3
- package/src/storage/implementation/MongoSyncBucketStorage.ts +114 -40
- package/src/storage/implementation/MongoSyncRulesLock.ts +22 -9
- package/src/storage/implementation/MongoWriteBatch.ts +172 -0
- package/src/storage/implementation/MongoWriteCheckpointAPI.ts +16 -46
- package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
- package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
- package/src/storage/implementation/common/PersistedBatch.ts +66 -10
- package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +4 -0
- package/src/storage/implementation/db.ts +35 -2
- package/src/storage/implementation/models.ts +34 -0
- package/src/storage/implementation/v1/MongoBucketBatchV1.ts +46 -6
- package/src/storage/implementation/v1/MongoCompactorV1.ts +327 -40
- package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
- package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +87 -27
- package/src/storage/implementation/v1/PersistedBatchV1.ts +25 -30
- package/src/storage/implementation/v1/models.ts +1 -0
- package/src/storage/implementation/v3/CompactionLease.ts +152 -0
- package/src/storage/implementation/v3/MongoBucketBatchV3.ts +138 -34
- package/src/storage/implementation/v3/MongoCompactorV3.ts +958 -225
- package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
- package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
- package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +111 -41
- package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
- package/src/storage/implementation/v3/PersistedBatchV3.ts +93 -48
- package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
- package/src/storage/implementation/v3/bucket-format.ts +2 -0
- package/src/storage/implementation/v3/compact-utils.ts +320 -0
- package/src/storage/implementation/v3/compaction-constants.ts +5 -0
- package/src/storage/implementation/v3/models.ts +91 -3
- package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +24 -9
- package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
- package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +26 -14
- package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +229 -0
- package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +279 -29
- package/src/storage/implementation/v3/source-table-utils.ts +39 -41
- package/src/storage/storage-index.ts +2 -0
- package/src/types/types.ts +42 -7
- package/src/utils/test-utils.ts +1 -0
- package/src/utils/util.ts +3 -1
- package/test/src/__snapshots__/storage.test.ts.snap +25 -0
- package/test/src/__snapshots__/storage_sync.test.ts.snap +605 -16
- package/test/src/chunk_compaction_config.test.ts +38 -0
- package/test/src/cleanup-stopped-sync-configs.test.ts +40 -13
- package/test/src/compact-utils.test.ts +226 -0
- package/test/src/helpers/MemoryObjectStorage.ts +14 -6
- package/test/src/object_storage_usage.test.ts +267 -0
- package/test/src/parameter_compacting_v1.test.ts +120 -0
- package/test/src/parameter_compacting_v3.test.ts +192 -0
- package/test/src/parameter_compaction_fence.test.ts +276 -0
- package/test/src/storage.test.ts +273 -22
- package/test/src/storage_compacting.test.ts +968 -246
- package/test/src/storage_s3_checksums.test.ts +3 -4
- package/test/src/storage_s3_compaction_lifecycle.test.ts +138 -12
- package/test/src/storage_s3_reading.test.ts +290 -2
- package/test/src/storage_s3_writing.test.ts +1 -0
- package/test/src/storage_sync.test.ts +336 -41
- package/test/src/util.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
storage,
|
|
5
5
|
updateSyncRulesFromYaml
|
|
6
6
|
} from '@powersync/service-core';
|
|
7
|
-
import { bucketRequest, test_utils } from '@powersync/service-core-tests';
|
|
7
|
+
import { bucketRequest, compactActive, test_utils } from '@powersync/service-core-tests';
|
|
8
8
|
import { describe, expect, test } from 'vitest';
|
|
9
9
|
import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
|
|
10
10
|
import { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
|
|
@@ -60,8 +60,7 @@ describe('V3 checksums with S3 object storage', () => {
|
|
|
60
60
|
{
|
|
61
61
|
$set: {
|
|
62
62
|
last_op: 3n,
|
|
63
|
-
compacted_state: { op_id: 3n, count: 0, checksum: 0n, bytes:
|
|
64
|
-
estimate_since_compact: { count: 3, bytes: 100 }
|
|
63
|
+
compacted_state: { op_id: 3n, count: 0, checksum: 0n, bytes: 0n, chunks: 0, at: new Date() }
|
|
65
64
|
}
|
|
66
65
|
},
|
|
67
66
|
{ upsert: true }
|
|
@@ -154,7 +153,7 @@ describe('V3 checksums with S3 object storage', () => {
|
|
|
154
153
|
const checkpoint = await bucketStorage.getCheckpoint();
|
|
155
154
|
|
|
156
155
|
// Compact — produces CLEAR doc from collapsed MOVEs
|
|
157
|
-
await
|
|
156
|
+
await compactActive(factory, {
|
|
158
157
|
maxOpId: checkpoint.checkpoint,
|
|
159
158
|
compactBuckets: [request.bucket],
|
|
160
159
|
clearBatchLimit: 200,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import * as lib_mongo from '@powersync/lib-service-mongodb';
|
|
1
2
|
import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
|
|
2
|
-
import { bucketRequest, test_utils } from '@powersync/service-core-tests';
|
|
3
|
+
import { bucketRequest, compactActive, test_utils } from '@powersync/service-core-tests';
|
|
3
4
|
import { describe, expect, test } from 'vitest';
|
|
4
5
|
import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
|
|
6
|
+
import { MongoSyncBucketStorageV3 } from '../../src/storage/implementation/v3/MongoSyncBucketStorageV3.js';
|
|
5
7
|
import { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
|
|
6
8
|
import { ObjectStorageError } from '../../src/storage/implementation/v3/object-storage/ObjectStorage.js';
|
|
7
9
|
import { env } from './env.js';
|
|
@@ -28,8 +30,30 @@ function memoryS3Factory(options: { inlineThresholdBytes?: number } = {}) {
|
|
|
28
30
|
return { memoryStorage: objectStorage, factory: factoryGen };
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
async function expectUsageMatchesBucketData(bucketStorage: MongoSyncBucketStorage, definitionId: string) {
|
|
34
|
+
const v3BucketStorage = bucketStorage as MongoSyncBucketStorageV3;
|
|
35
|
+
const db = v3BucketStorage.db as VersionedPowerSyncMongoV3;
|
|
36
|
+
const documents = await db.bucketData(v3BucketStorage.replicationStreamId, definitionId).find({}).toArray();
|
|
37
|
+
const expectedBytes = documents.reduce((sum, document) => sum + BigInt(document.storage_ref?.file_size ?? 0), 0n);
|
|
38
|
+
const entries = await db.objectStorageUsage
|
|
39
|
+
.aggregate<{ active_bytes: bigint }>([
|
|
40
|
+
{ $match: { '_id.g': v3BucketStorage.replicationStreamId } },
|
|
41
|
+
{ $project: { definitions: { $objectToArray: '$definitions' } } },
|
|
42
|
+
{ $unwind: '$definitions' },
|
|
43
|
+
{ $group: { _id: null, active_bytes: { $sum: '$definitions.v' } } }
|
|
44
|
+
])
|
|
45
|
+
.toArray()
|
|
46
|
+
.catch((error) => {
|
|
47
|
+
if (lib_mongo.isMongoNamespaceNotFoundError(error)) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
});
|
|
52
|
+
expect(BigInt(entries[0]?.active_bytes ?? 0)).toBe(expectedBytes);
|
|
53
|
+
}
|
|
54
|
+
|
|
31
55
|
describe('S3 compaction storage lifecycle', () => {
|
|
32
|
-
test('
|
|
56
|
+
test('a later compaction recovers from a transient object storage failure', async () => {
|
|
33
57
|
const { memoryStorage, factory: factoryGen } = memoryS3Factory();
|
|
34
58
|
await using factory = await factoryGen.factory();
|
|
35
59
|
const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
|
|
@@ -63,12 +87,14 @@ describe('S3 compaction storage lifecycle', () => {
|
|
|
63
87
|
|
|
64
88
|
const checkpoint = await bucketStorage.getCheckpoint();
|
|
65
89
|
const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
|
|
66
|
-
|
|
90
|
+
const compactOptions = {
|
|
67
91
|
maxOpId: checkpoint.checkpoint,
|
|
68
92
|
compactBuckets: [request.bucket],
|
|
69
93
|
minBucketChanges: 1,
|
|
70
94
|
minChangeRatio: 0
|
|
71
|
-
}
|
|
95
|
+
};
|
|
96
|
+
await expect(compactActive(factory, compactOptions)).rejects.toThrow('temporary object storage failure');
|
|
97
|
+
await compactActive(factory, compactOptions);
|
|
72
98
|
|
|
73
99
|
expect(injectedFailure).toBe(true);
|
|
74
100
|
const batch = await test_utils.getBatchArray(bucketStorage.getBucketDataBatch(checkpoint, [request]));
|
|
@@ -107,7 +133,7 @@ describe('S3 compaction storage lifecycle', () => {
|
|
|
107
133
|
|
|
108
134
|
const checkpoint = await bucketStorage.getCheckpoint();
|
|
109
135
|
const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
|
|
110
|
-
await
|
|
136
|
+
await compactActive(factory, {
|
|
111
137
|
maxOpId: checkpoint.checkpoint,
|
|
112
138
|
compactBuckets: [request.bucket],
|
|
113
139
|
minBucketChanges: 1,
|
|
@@ -119,6 +145,103 @@ describe('S3 compaction storage lifecycle', () => {
|
|
|
119
145
|
expect(docs[0].storage_ref).toBeUndefined();
|
|
120
146
|
expect(docs[0].ops).toBeDefined();
|
|
121
147
|
expect(memoryStorage.store.size).toBe(0);
|
|
148
|
+
await expectUsageMatchesBucketData(bucketStorage, definitionId);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('boundary CLEAR replacement updates active object-storage usage', async () => {
|
|
152
|
+
const { factory: factoryGen } = memoryS3Factory();
|
|
153
|
+
await using factory = await factoryGen.factory();
|
|
154
|
+
const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
|
|
155
|
+
const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
|
|
156
|
+
|
|
157
|
+
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
158
|
+
const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 11);
|
|
159
|
+
await writer.markAllSnapshotDone('1/1');
|
|
160
|
+
for (const [index, description] of ['first', 'second', 'latest'].entries()) {
|
|
161
|
+
await writer.save({
|
|
162
|
+
sourceTable,
|
|
163
|
+
tag: index === 0 ? storage.SaveOperationTag.INSERT : storage.SaveOperationTag.UPDATE,
|
|
164
|
+
after: { id: 'A', description: description.repeat(100) },
|
|
165
|
+
afterReplicaId: test_utils.rid('A')
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
await writer.commit('1/1');
|
|
169
|
+
|
|
170
|
+
const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
|
|
171
|
+
const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
|
|
172
|
+
const checkpoint = await bucketStorage.getCheckpoint();
|
|
173
|
+
await compactActive(factory, {
|
|
174
|
+
maxOpId: checkpoint.checkpoint,
|
|
175
|
+
compactBuckets: [request.bucket],
|
|
176
|
+
minBucketChanges: 1,
|
|
177
|
+
minChangeRatio: 0
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const batch = await test_utils.getBatchArray(bucketStorage.getBucketDataBatch(checkpoint, [request]));
|
|
181
|
+
const data = batch.flatMap((chunk) => chunk.chunkData.data);
|
|
182
|
+
expect(data.some((op: any) => op.op === 'CLEAR')).toBe(true);
|
|
183
|
+
await expectUsageMatchesBucketData(bucketStorage, definitionId);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('leading and boundary CLEAR replacements update active object-storage usage', async () => {
|
|
187
|
+
const { factory: factoryGen } = memoryS3Factory();
|
|
188
|
+
await using factory = await factoryGen.factory();
|
|
189
|
+
const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_RULES_YAML, { storageVersion: 3 }));
|
|
190
|
+
const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
|
|
191
|
+
|
|
192
|
+
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
193
|
+
const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 12);
|
|
194
|
+
await writer.markAllSnapshotDone('1/1');
|
|
195
|
+
|
|
196
|
+
// The first and last A versions force a leading MOVE document. The
|
|
197
|
+
// middle document also contains a large surviving PUT, keeping it as the
|
|
198
|
+
// CLEAR boundary document rather than merging it with the leading group.
|
|
199
|
+
await writer.save({
|
|
200
|
+
sourceTable,
|
|
201
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
202
|
+
after: { id: 'A', description: 'old' },
|
|
203
|
+
afterReplicaId: test_utils.rid('A')
|
|
204
|
+
});
|
|
205
|
+
await writer.commit('1/1');
|
|
206
|
+
|
|
207
|
+
await writer.save({
|
|
208
|
+
sourceTable,
|
|
209
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
210
|
+
after: { id: 'A', description: 'middle' },
|
|
211
|
+
afterReplicaId: test_utils.rid('A')
|
|
212
|
+
});
|
|
213
|
+
await writer.save({
|
|
214
|
+
sourceTable,
|
|
215
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
216
|
+
after: { id: 'B', description: 'b'.repeat(990_000) },
|
|
217
|
+
afterReplicaId: test_utils.rid('B')
|
|
218
|
+
});
|
|
219
|
+
await writer.commit('2/1');
|
|
220
|
+
|
|
221
|
+
await writer.save({
|
|
222
|
+
sourceTable,
|
|
223
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
224
|
+
after: { id: 'A', description: 'latest'.repeat(10_000) },
|
|
225
|
+
afterReplicaId: test_utils.rid('A')
|
|
226
|
+
});
|
|
227
|
+
await writer.commit('3/1');
|
|
228
|
+
|
|
229
|
+
const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
|
|
230
|
+
const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
|
|
231
|
+
const checkpoint = await bucketStorage.getCheckpoint();
|
|
232
|
+
await compactActive(factory, {
|
|
233
|
+
maxOpId: checkpoint.checkpoint,
|
|
234
|
+
compactBuckets: [request.bucket],
|
|
235
|
+
clearBatchLimit: 2,
|
|
236
|
+
moveBatchQueryLimit: 1,
|
|
237
|
+
minBucketChanges: 1,
|
|
238
|
+
minChangeRatio: 0
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const batch = await test_utils.getBatchArray(bucketStorage.getBucketDataBatch(checkpoint, [request]));
|
|
242
|
+
const data = batch.flatMap((chunk) => chunk.chunkData.data);
|
|
243
|
+
expect(data.some((op: any) => op.op === 'CLEAR')).toBe(true);
|
|
244
|
+
await expectUsageMatchesBucketData(bucketStorage, definitionId);
|
|
122
245
|
});
|
|
123
246
|
|
|
124
247
|
test('MOVE compaction merges adjacent objects using their compacted size in one write', async () => {
|
|
@@ -167,7 +290,7 @@ describe('S3 compaction storage lifecycle', () => {
|
|
|
167
290
|
|
|
168
291
|
const checkpoint = await bucketStorage.getCheckpoint();
|
|
169
292
|
const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
|
|
170
|
-
await
|
|
293
|
+
await compactActive(factory, {
|
|
171
294
|
maxOpId: checkpoint.checkpoint,
|
|
172
295
|
compactBuckets: [request.bucket],
|
|
173
296
|
clearBatchLimit: 200,
|
|
@@ -193,6 +316,7 @@ describe('S3 compaction storage lifecycle', () => {
|
|
|
193
316
|
// final replacement was uploaded. There was no intermediate MOVE-only
|
|
194
317
|
// object before the merge.
|
|
195
318
|
expect(objectStore.size).toBe(4);
|
|
319
|
+
await expectUsageMatchesBucketData(bucketStorage, definitionId);
|
|
196
320
|
});
|
|
197
321
|
|
|
198
322
|
test('compaction only retires S3 objects at or below the maxOpId horizon', async () => {
|
|
@@ -230,7 +354,7 @@ describe('S3 compaction storage lifecycle', () => {
|
|
|
230
354
|
const lowerPaths = new Set(docsBefore.slice(0, 6).map((doc) => doc.storage_ref!.path));
|
|
231
355
|
const upperPaths = new Set(docsBefore.slice(6).map((doc) => doc.storage_ref!.path));
|
|
232
356
|
|
|
233
|
-
await
|
|
357
|
+
await compactActive(factory, {
|
|
234
358
|
maxOpId,
|
|
235
359
|
compactBuckets: [request.bucket],
|
|
236
360
|
clearBatchLimit: 200,
|
|
@@ -257,6 +381,7 @@ describe('S3 compaction storage lifecycle', () => {
|
|
|
257
381
|
|
|
258
382
|
// Retired objects remain readable during the reference grace period.
|
|
259
383
|
expect([...lowerPaths].every((path) => memoryStorage.store.has(path))).toBe(true);
|
|
384
|
+
await expectUsageMatchesBucketData(bucketStorage, definitionId);
|
|
260
385
|
|
|
261
386
|
const batch = await test_utils.getBatchArray(bucketStorage.getBucketDataBatch(readCheckpoint, [request]));
|
|
262
387
|
const data = batch.flatMap((chunk) => chunk.chunkData.data);
|
|
@@ -337,14 +462,14 @@ describe('S3 compaction storage lifecycle', () => {
|
|
|
337
462
|
const request = bucketRequest(syncRules.syncConfigContent[0], 'global[]', 0n);
|
|
338
463
|
const bucket = request.bucket;
|
|
339
464
|
|
|
340
|
-
// Read bucket_state before compaction to confirm
|
|
341
|
-
//
|
|
465
|
+
// Read bucket_state before compaction to confirm the writer recorded its
|
|
466
|
+
// aggregate statistics and scheduled a compact check.
|
|
342
467
|
const bucketStateBefore = await bucketStateCollection.findOne({
|
|
343
468
|
_id: { d: definitionId, b: bucket }
|
|
344
469
|
});
|
|
345
470
|
expect(bucketStateBefore).toBeDefined();
|
|
346
|
-
expect(bucketStateBefore!.
|
|
347
|
-
expect(bucketStateBefore!.
|
|
471
|
+
expect(bucketStateBefore!.bucket_stats.count).toBeGreaterThan(0);
|
|
472
|
+
expect(bucketStateBefore!.next_compact_check).toBeDefined();
|
|
348
473
|
|
|
349
474
|
// Record the input operations and object path.
|
|
350
475
|
const batchBefore = await test_utils.getBatchArray(bucketStorage.getBucketDataBatch(checkpoint, [request]));
|
|
@@ -357,7 +482,7 @@ describe('S3 compaction storage lifecycle', () => {
|
|
|
357
482
|
expect(expectedCompactedOpId).not.toBeNull();
|
|
358
483
|
|
|
359
484
|
// Compact the bucket.
|
|
360
|
-
await
|
|
485
|
+
await compactActive(factory, {
|
|
361
486
|
maxOpId: checkpoint.checkpoint,
|
|
362
487
|
compactBuckets: [bucket],
|
|
363
488
|
clearBatchLimit: 200,
|
|
@@ -402,6 +527,7 @@ describe('S3 compaction storage lifecycle', () => {
|
|
|
402
527
|
for (const path of afterS3Paths) {
|
|
403
528
|
expect(storedPaths.has(path)).toBe(true);
|
|
404
529
|
}
|
|
530
|
+
await expectUsageMatchesBucketData(bucketStorage, definitionId);
|
|
405
531
|
|
|
406
532
|
// The replacement object is readable and contains the expected compacted
|
|
407
533
|
// operation sequence.
|
|
@@ -2,12 +2,16 @@ import { DeleteObjectsCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
|
|
2
2
|
import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
|
|
3
3
|
import { bucketRequest, test_utils } from '@powersync/service-core-tests';
|
|
4
4
|
import * as bson from 'bson';
|
|
5
|
-
import { describe, expect, test } from 'vitest';
|
|
5
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
6
6
|
import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
|
|
7
7
|
import { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
|
|
8
8
|
import { hydrateBucketDataDocuments } from '../../src/storage/implementation/v3/object-storage/BucketDataObjectStorage.js';
|
|
9
9
|
import { ObjectStorageError } from '../../src/storage/implementation/v3/object-storage/ObjectStorage.js';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
resolveTimeoutProfile,
|
|
12
|
+
S3ObjectStorage,
|
|
13
|
+
type S3TimeoutProfile
|
|
14
|
+
} from '../../src/storage/implementation/v3/object-storage/S3ObjectStorage.js';
|
|
11
15
|
import { env } from './env.js';
|
|
12
16
|
import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
|
|
13
17
|
import { createMemoryS3TestStorageSuite, createS3TestStorageSuite } from './helpers/s3TestFactory.js';
|
|
@@ -62,6 +66,234 @@ describe('S3 object storage reads', () => {
|
|
|
62
66
|
);
|
|
63
67
|
});
|
|
64
68
|
|
|
69
|
+
test('derives request timeouts from the AWS defaults mode', () => {
|
|
70
|
+
// legacy - the mode used when nothing is configured - defines no timeouts of its own,
|
|
71
|
+
// and uses the same baseline as standard.
|
|
72
|
+
expect(resolveTimeoutProfile('legacy')).toEqual({
|
|
73
|
+
connectionTimeoutMs: 3_100,
|
|
74
|
+
requestTimeoutMs: 15_500,
|
|
75
|
+
operationTimeoutMs: 31_000,
|
|
76
|
+
queueTimeoutMs: 124_000
|
|
77
|
+
});
|
|
78
|
+
expect(resolveTimeoutProfile('standard')).toEqual(resolveTimeoutProfile('legacy'));
|
|
79
|
+
expect(resolveTimeoutProfile('cross-region')).toEqual(resolveTimeoutProfile('legacy'));
|
|
80
|
+
expect(resolveTimeoutProfile('in-region')).toEqual({
|
|
81
|
+
connectionTimeoutMs: 1_100,
|
|
82
|
+
requestTimeoutMs: 5_500,
|
|
83
|
+
operationTimeoutMs: 11_000,
|
|
84
|
+
queueTimeoutMs: 44_000
|
|
85
|
+
});
|
|
86
|
+
expect(resolveTimeoutProfile('mobile')).toEqual({
|
|
87
|
+
connectionTimeoutMs: 30_000,
|
|
88
|
+
requestTimeoutMs: 150_000,
|
|
89
|
+
operationTimeoutMs: 300_000,
|
|
90
|
+
queueTimeoutMs: 1_200_000
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('resolves explicit and environment defaults modes without waiting on configuration', async () => {
|
|
95
|
+
// The timeouts are resolved up front, so operations never wait on configuration. The client
|
|
96
|
+
// wraps the mode in a provider of its own, which resolves to the same value.
|
|
97
|
+
const modeOf = async (objectStorage: S3ObjectStorage) => {
|
|
98
|
+
const configured = objectStorage.client.config.defaultsMode;
|
|
99
|
+
return typeof configured === 'function' ? await configured() : configured;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const previousMode = process.env.AWS_DEFAULTS_MODE;
|
|
103
|
+
try {
|
|
104
|
+
process.env.AWS_DEFAULTS_MODE = 'cross-region';
|
|
105
|
+
await expect(modeOf(new S3ObjectStorage({ bucket: 'test', region: 'test' }))).resolves.toEqual('cross-region');
|
|
106
|
+
|
|
107
|
+
// The storage option takes precedence over the environment.
|
|
108
|
+
await expect(
|
|
109
|
+
modeOf(new S3ObjectStorage({ bucket: 'test', region: 'test', defaultsMode: 'in-region' }))
|
|
110
|
+
).resolves.toEqual('in-region');
|
|
111
|
+
|
|
112
|
+
// 'auto' would query the EC2 instance metadata service, and requires a resolvable region.
|
|
113
|
+
await expect(modeOf(new S3ObjectStorage({ bucket: 'test', defaultsMode: 'auto' }))).resolves.toEqual('standard');
|
|
114
|
+
|
|
115
|
+
delete process.env.AWS_DEFAULTS_MODE;
|
|
116
|
+
await expect(modeOf(new S3ObjectStorage({ bucket: 'test', region: 'test' }))).resolves.toEqual('legacy');
|
|
117
|
+
|
|
118
|
+
// An invalid mode fails on startup, rather than on every operation.
|
|
119
|
+
expect(
|
|
120
|
+
() => new S3ObjectStorage({ bucket: 'test', region: 'test', defaultsMode: 'nonsense' as any })
|
|
121
|
+
).toThrowError(/Invalid AWS defaults mode "nonsense"/);
|
|
122
|
+
} finally {
|
|
123
|
+
if (previousMode == null) {
|
|
124
|
+
delete process.env.AWS_DEFAULTS_MODE;
|
|
125
|
+
} else {
|
|
126
|
+
process.env.AWS_DEFAULTS_MODE = previousMode;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('cancels active uploads and deletes', async () => {
|
|
132
|
+
const operations = [
|
|
133
|
+
(objectStorage: S3ObjectStorage, signal: AbortSignal) =>
|
|
134
|
+
objectStorage.put(
|
|
135
|
+
'object',
|
|
136
|
+
new Uint8Array(),
|
|
137
|
+
{ contentType: 'application/bson', contentEncoding: null },
|
|
138
|
+
{ signal }
|
|
139
|
+
),
|
|
140
|
+
(objectStorage: S3ObjectStorage, signal: AbortSignal) => objectStorage.delete(['object'], { signal })
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
for (const startOperation of operations) {
|
|
144
|
+
const objectStorage = new S3ObjectStorage({ bucket: 'test', region: 'test' });
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
let requestStarted!: () => void;
|
|
147
|
+
const started = new Promise<void>((resolve) => (requestStarted = resolve));
|
|
148
|
+
objectStorage.client.send = async (_command: any, options: any) => {
|
|
149
|
+
requestStarted();
|
|
150
|
+
await new Promise<void>((_resolve, reject) => {
|
|
151
|
+
const abort = () => reject(options.abortSignal.reason);
|
|
152
|
+
if (options.abortSignal.aborted) {
|
|
153
|
+
abort();
|
|
154
|
+
} else {
|
|
155
|
+
options.abortSignal.addEventListener('abort', abort, { once: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
throw new Error('unreachable');
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const running = startOperation(objectStorage, controller.signal);
|
|
162
|
+
const expectation = expect(running).rejects.toMatchObject({ name: 'AbortError' });
|
|
163
|
+
await started;
|
|
164
|
+
controller.abort();
|
|
165
|
+
await expectation;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('reports why a streaming operation was cancelled', async () => {
|
|
170
|
+
// Aborting mid-stream destroys the socket, and the stream fails with a generic 'aborted'
|
|
171
|
+
// error. The operation reports the actual cause instead.
|
|
172
|
+
const stalledDownload = (objectStorage: S3ObjectStorage) => {
|
|
173
|
+
objectStorage.client.send = async (_command: any, options: any) => ({
|
|
174
|
+
ContentLength: 10,
|
|
175
|
+
ContentType: 'application/bson',
|
|
176
|
+
Body: (async function* () {
|
|
177
|
+
yield new Uint8Array([1, 2, 3]);
|
|
178
|
+
await new Promise<void>((_resolve, reject) => {
|
|
179
|
+
const abort = () => reject(new Error('stream aborted'));
|
|
180
|
+
if (options.abortSignal.aborted) {
|
|
181
|
+
abort();
|
|
182
|
+
} else {
|
|
183
|
+
options.abortSignal.addEventListener('abort', abort, { once: true });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
})()
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const timingOut = new S3ObjectStorage({ bucket: 'test', region: 'test' });
|
|
191
|
+
(timingOut as any).timeouts = {
|
|
192
|
+
...resolveTimeoutProfile('standard'),
|
|
193
|
+
operationTimeoutMs: 50
|
|
194
|
+
} satisfies S3TimeoutProfile;
|
|
195
|
+
stalledDownload(timingOut);
|
|
196
|
+
await expect(timingOut.get('stalled-object')).rejects.toMatchObject({
|
|
197
|
+
name: 'ObjectStorageError',
|
|
198
|
+
retryable: true,
|
|
199
|
+
message: expect.stringContaining('did not complete within 50 ms')
|
|
200
|
+
} satisfies Partial<ObjectStorageError>);
|
|
201
|
+
|
|
202
|
+
// A caller abort is a cancellation, not a retryable failure.
|
|
203
|
+
const cancelled = new S3ObjectStorage({ bucket: 'test', region: 'test' });
|
|
204
|
+
stalledDownload(cancelled);
|
|
205
|
+
const controller = new AbortController();
|
|
206
|
+
const reading = cancelled.get('stalled-object', { signal: controller.signal });
|
|
207
|
+
const expectation = expect(reading).rejects.toMatchObject({ name: 'AbortError' });
|
|
208
|
+
controller.abort();
|
|
209
|
+
await expectation;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('reports the caller abort reason unchanged', async () => {
|
|
213
|
+
// Callers abort with the error that stopped them, and upstream error handling matches on that
|
|
214
|
+
// type. Replacing it with an ObjectStorageError would hide it - for MongoDB replication, that
|
|
215
|
+
// loses the invalidation error that triggers a replication restart.
|
|
216
|
+
class StreamInvalidatedError extends Error {}
|
|
217
|
+
|
|
218
|
+
const objectStorage = new S3ObjectStorage({ bucket: 'test', region: 'test' });
|
|
219
|
+
const reason = new StreamInvalidatedError('stream invalidated');
|
|
220
|
+
const controller = new AbortController();
|
|
221
|
+
objectStorage.client.send = async (_command: any, options: any) => {
|
|
222
|
+
controller.abort(reason);
|
|
223
|
+
throw options.abortSignal.reason;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
await expect(objectStorage.get('object', { signal: controller.signal })).rejects.toBe(reason);
|
|
227
|
+
await expect(
|
|
228
|
+
objectStorage.put(
|
|
229
|
+
'object',
|
|
230
|
+
new Uint8Array(),
|
|
231
|
+
{ contentType: 'application/bson', contentEncoding: null },
|
|
232
|
+
{ signal: controller.signal }
|
|
233
|
+
)
|
|
234
|
+
).rejects.toBe(reason);
|
|
235
|
+
await expect(objectStorage.delete(['object'], { signal: controller.signal })).rejects.toBe(reason);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('times out waiting for an operation slot', async () => {
|
|
239
|
+
const objectStorage = new S3ObjectStorage({ bucket: 'test', region: 'test', concurrencyLimit: 1 });
|
|
240
|
+
(objectStorage as any).timeouts = {
|
|
241
|
+
connectionTimeoutMs: 10,
|
|
242
|
+
requestTimeoutMs: 20,
|
|
243
|
+
operationTimeoutMs: 10_000,
|
|
244
|
+
queueTimeoutMs: 50
|
|
245
|
+
} satisfies S3TimeoutProfile;
|
|
246
|
+
let releaseBlocker!: () => void;
|
|
247
|
+
const blocked = new Promise<void>((resolve) => (releaseBlocker = resolve));
|
|
248
|
+
objectStorage.client.send = async () => {
|
|
249
|
+
await blocked;
|
|
250
|
+
return { ContentLength: 0, Body: (async function* () {})(), ContentType: 'application/bson' };
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// The single slot is held by the first upload, which has no signal of its own.
|
|
254
|
+
const holding = objectStorage.put('held-object', new Uint8Array(), {
|
|
255
|
+
contentType: 'application/bson',
|
|
256
|
+
contentEncoding: null
|
|
257
|
+
});
|
|
258
|
+
await expect(objectStorage.get('queued-object')).rejects.toMatchObject({
|
|
259
|
+
name: 'ObjectStorageError',
|
|
260
|
+
retryable: true,
|
|
261
|
+
message: expect.stringContaining('waiting for a concurrency slot')
|
|
262
|
+
} satisfies Partial<ObjectStorageError>);
|
|
263
|
+
|
|
264
|
+
releaseBlocker();
|
|
265
|
+
await holding;
|
|
266
|
+
// The slot is usable again once the blocking operation completes.
|
|
267
|
+
await expect(objectStorage.get('later-object')).resolves.toMatchObject({ data: new Uint8Array(0) });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('reports a caller abort while queued as a cancellation', async () => {
|
|
271
|
+
const objectStorage = new S3ObjectStorage({ bucket: 'test', region: 'test', concurrencyLimit: 1 });
|
|
272
|
+
const controller = new AbortController();
|
|
273
|
+
let holderStarted!: () => void;
|
|
274
|
+
const started = new Promise<void>((resolve) => (holderStarted = resolve));
|
|
275
|
+
let releaseHolder!: () => void;
|
|
276
|
+
const blocked = new Promise<void>((resolve) => (releaseHolder = resolve));
|
|
277
|
+
objectStorage.client.send = async () => {
|
|
278
|
+
holderStarted();
|
|
279
|
+
await blocked;
|
|
280
|
+
return {};
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const holding = objectStorage.put('held-object', new Uint8Array(), {
|
|
284
|
+
contentType: 'application/bson',
|
|
285
|
+
contentEncoding: null
|
|
286
|
+
});
|
|
287
|
+
await started;
|
|
288
|
+
|
|
289
|
+
const queuedRead = objectStorage.get('queued-object', { signal: controller.signal });
|
|
290
|
+
const expectation = expect(queuedRead).rejects.toMatchObject({ name: 'AbortError' });
|
|
291
|
+
controller.abort();
|
|
292
|
+
await expectation;
|
|
293
|
+
releaseHolder();
|
|
294
|
+
await holding;
|
|
295
|
+
});
|
|
296
|
+
|
|
65
297
|
test('rejects complete S3 keys over the safe byte limit', async () => {
|
|
66
298
|
const objectStorage = new S3ObjectStorage({
|
|
67
299
|
bucket: 'test',
|
|
@@ -125,6 +357,16 @@ describe('S3 object storage reads', () => {
|
|
|
125
357
|
message: expect.stringContaining('S3 download failed for transient-object')
|
|
126
358
|
} satisfies Partial<ObjectStorageError>);
|
|
127
359
|
|
|
360
|
+
const timeoutCause = new DOMException('The operation was aborted due to timeout', 'TimeoutError');
|
|
361
|
+
objectStorage.client.send = async () => {
|
|
362
|
+
throw timeoutCause;
|
|
363
|
+
};
|
|
364
|
+
await expect(objectStorage.get('timed-out-object')).rejects.toMatchObject({
|
|
365
|
+
name: 'ObjectStorageError',
|
|
366
|
+
retryable: true,
|
|
367
|
+
cause: timeoutCause
|
|
368
|
+
} satisfies Partial<ObjectStorageError>);
|
|
369
|
+
|
|
128
370
|
const permanentCause = Object.assign(new Error('access denied'), {
|
|
129
371
|
name: 'AccessDenied',
|
|
130
372
|
$metadata: { httpStatusCode: 403 }
|
|
@@ -334,6 +576,52 @@ describe('S3 object storage reads', () => {
|
|
|
334
576
|
expect(maxActiveOperations).toBe(4);
|
|
335
577
|
});
|
|
336
578
|
|
|
579
|
+
test('drains sibling downloads before reporting a hydration failure', async () => {
|
|
580
|
+
const objectStorage = new MemoryObjectStorage();
|
|
581
|
+
const gate = Promise.withResolvers<void>();
|
|
582
|
+
const failure = new Error('download failed');
|
|
583
|
+
const documents = ['failed', 'pending'].map((path, index) => ({
|
|
584
|
+
_id: { b: 'bucket', o: BigInt(index + 1) },
|
|
585
|
+
min_op: BigInt(index + 1),
|
|
586
|
+
checksum: 0n,
|
|
587
|
+
count: 0,
|
|
588
|
+
size: 1,
|
|
589
|
+
storage_ref: { path, file_size: 1 }
|
|
590
|
+
}));
|
|
591
|
+
const get = vi.spyOn(objectStorage, 'get').mockImplementation(async (path) => {
|
|
592
|
+
if (path === 'failed') {
|
|
593
|
+
throw failure;
|
|
594
|
+
}
|
|
595
|
+
await gate.promise;
|
|
596
|
+
return {
|
|
597
|
+
data: bson.serialize({ ops: [] }),
|
|
598
|
+
metadata: { contentType: 'application/bson', contentEncoding: null }
|
|
599
|
+
};
|
|
600
|
+
});
|
|
601
|
+
let settled = false;
|
|
602
|
+
const result = hydrateBucketDataDocuments(documents, objectStorage, {}).then(
|
|
603
|
+
() => {
|
|
604
|
+
settled = true;
|
|
605
|
+
return undefined;
|
|
606
|
+
},
|
|
607
|
+
(error) => {
|
|
608
|
+
settled = true;
|
|
609
|
+
return error;
|
|
610
|
+
}
|
|
611
|
+
);
|
|
612
|
+
try {
|
|
613
|
+
await vi.waitFor(() => expect(get).toHaveBeenCalledTimes(2));
|
|
614
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
615
|
+
expect(settled).toBe(false);
|
|
616
|
+
} finally {
|
|
617
|
+
gate.resolve();
|
|
618
|
+
await result;
|
|
619
|
+
get.mockRestore();
|
|
620
|
+
}
|
|
621
|
+
expect(await result).toBe(failure);
|
|
622
|
+
expect(documents[1]).toHaveProperty('ops', []);
|
|
623
|
+
});
|
|
624
|
+
|
|
337
625
|
test('aborts active object downloads', async () => {
|
|
338
626
|
const objectStorage = new MemoryObjectStorage();
|
|
339
627
|
const controller = new AbortController();
|
|
@@ -65,6 +65,7 @@ describe('S3 object storage writes', () => {
|
|
|
65
65
|
{ op: 'PUT', row_id: 'item2' }
|
|
66
66
|
]);
|
|
67
67
|
expect(ops.every((op) => op.data != null)).toBe(true);
|
|
68
|
+
expect(ops.every((op) => typeof op.subkey == 'string')).toBe(true);
|
|
68
69
|
|
|
69
70
|
// Verify the corresponding MongoDB metadata shell.
|
|
70
71
|
const db = bucketStorage.db as VersionedPowerSyncMongoV3;
|