@platformatic/sqlite-js-stream-s3 0.3.0 → 0.4.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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/replica-client.d.ts +50 -0
- package/dist/replica-client.d.ts.map +1 -1
- package/dist/replica-client.js +134 -14
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/replica-client.ts +178 -13
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
* factory, so `newReplicaClientFromURL('s3://bucket/prefix')` resolves after
|
|
6
6
|
* this addon is loaded.
|
|
7
7
|
*/
|
|
8
|
-
export { S3ReplicaClient, newReplicaClientFromURL, isFenced, GenerationMetadataKey, GenerationObjectName, ReplicaClientType, type GenerationFence, type GenerationState, type S3ClientLike, type S3ReplicaClientOptions, } from './replica-client.ts';
|
|
8
|
+
export { S3ReplicaClient, newReplicaClientFromURL, isFenced, GenerationMetadataKey, GenerationObjectName, ReplicaClientType, type GenerationFence, type GenerationState, type S3ClientLike, type S3DeletePrefixOptions, type S3ReplicaClientOptions, } from './replica-client.ts';
|
|
9
9
|
export { S3Leaser } from './leaser.ts';
|
|
10
10
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EACL,eAAe,EACf,uBAAuB,EACvB,QAAQ,EACR,qBAAqB,EACrB,oBAAoB,EACpB,iBAAiB,EACjB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,sBAAsB,GAC5B,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EACL,eAAe,EACf,uBAAuB,EACvB,QAAQ,EACR,qBAAqB,EACrB,oBAAoB,EACpB,iBAAiB,EACjB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,GAC5B,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/replica-client.d.ts
CHANGED
|
@@ -65,6 +65,33 @@ export interface S3ReplicaClientOptions {
|
|
|
65
65
|
*/
|
|
66
66
|
generation?: bigint | null;
|
|
67
67
|
}
|
|
68
|
+
/** Configuration for {@link S3ReplicaClient.deletePrefix}. */
|
|
69
|
+
export interface S3DeletePrefixOptions {
|
|
70
|
+
/** S3 bucket name. Required. */
|
|
71
|
+
bucket: string;
|
|
72
|
+
/**
|
|
73
|
+
* Prefix to delete: the replica at this prefix and every replica under
|
|
74
|
+
* `prefix/` (`a` covers `a` and `a/b`, not `ab`). Leading/trailing slashes
|
|
75
|
+
* are normalized away. Must not be empty.
|
|
76
|
+
*/
|
|
77
|
+
prefix: string;
|
|
78
|
+
/** AWS region. Defaults to the AWS SDK region resolution chain. */
|
|
79
|
+
region?: string;
|
|
80
|
+
/** Custom endpoint URL (e.g. for MinIO / LocalStack). */
|
|
81
|
+
endpoint?: string;
|
|
82
|
+
accessKeyId?: string;
|
|
83
|
+
secretAccessKey?: string;
|
|
84
|
+
sessionToken?: string;
|
|
85
|
+
/** Inject a preconfigured client. */
|
|
86
|
+
client?: S3ClientLike;
|
|
87
|
+
/**
|
|
88
|
+
* Fences the deletion: it fails with {@link ErrGenerationSuperseded} if a
|
|
89
|
+
* replica under the prefix has a higher generation, checked before deleting
|
|
90
|
+
* any of its objects (best effort, see {@link S3ReplicaClient.deletePrefix}).
|
|
91
|
+
* `null` (the default) deletes unconditionally.
|
|
92
|
+
*/
|
|
93
|
+
generation?: bigint | null;
|
|
94
|
+
}
|
|
68
95
|
/** Name of the generation object, relative to the replica prefix. */
|
|
69
96
|
export declare const GenerationObjectName = "generation.json";
|
|
70
97
|
/** Object metadata key carrying the generation of a fenced LTX upload. */
|
|
@@ -172,6 +199,29 @@ export declare class S3ReplicaClient implements ReplicaClient {
|
|
|
172
199
|
* (a deletion sent just before a new generation registers still applies).
|
|
173
200
|
*/
|
|
174
201
|
deleteLtxFiles(infos: FileInfo[]): Promise<void>;
|
|
202
|
+
/**
|
|
203
|
+
* Deletes every replica under a prefix: every object whose key starts with
|
|
204
|
+
* `prefix/`, which covers the replica at `prefix` and every replica under
|
|
205
|
+
* it, generation objects (`generation.json`) and lease objects included
|
|
206
|
+
* (unlike {@link deleteAll}, which keeps the generation). Resolves with the
|
|
207
|
+
* sorted prefixes of the deleted replicas: those with an LTX file, a
|
|
208
|
+
* generation object or a lease object.
|
|
209
|
+
*
|
|
210
|
+
* The generation objects are deleted last, so former writers stay fenced
|
|
211
|
+
* while the files are deleted. Once they are gone, nothing fences those
|
|
212
|
+
* writers anymore: a writer that is still running can upload again and
|
|
213
|
+
* create the replica anew. Stop the writers first.
|
|
214
|
+
*
|
|
215
|
+
* With {@link S3DeletePrefixOptions.generation}, the deletion is fenced as
|
|
216
|
+
* the deletions of a fenced client are, on a best-effort basis: the
|
|
217
|
+
* generation object of each replica is read before deleting its first
|
|
218
|
+
* object, and again before deleting the generation objects, and the
|
|
219
|
+
* deletion fails with {@link ErrGenerationSuperseded} if it holds a higher
|
|
220
|
+
* generation. S3 cannot check one object and delete another atomically, so
|
|
221
|
+
* a generation registered between a check and a `DeleteObjects` request
|
|
222
|
+
* does not stop it, and the replicas already processed stay deleted.
|
|
223
|
+
*/
|
|
224
|
+
static deletePrefix(opts: S3DeletePrefixOptions): Promise<string[]>;
|
|
175
225
|
/**
|
|
176
226
|
* Deletes every object under the prefix except the generation object, so a
|
|
177
227
|
* superseded writer stays fenced. A fenced client checks its generation
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"replica-client.d.ts","sourceRoot":"","sources":["../src/replica-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAgBvC,OAAO,
|
|
1
|
+
{"version":3,"file":"replica-client.d.ts","sourceRoot":"","sources":["../src/replica-client.ts"],"names":[],"mappings":"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"}
|
package/dist/replica-client.js
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { Readable } from 'node:stream';
|
|
35
35
|
import { S3Client, AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, DeleteObjectsCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, UploadPartCommand, } from '@aws-sdk/client-s3';
|
|
36
|
-
import { ErrGenerationSuperseded, HeaderSize, LTXError, SnapshotLevel, formatFilename, noopLogger, parseFilename, unmarshalHeader, } from '@platformatic/sqlite-js-stream';
|
|
36
|
+
import { DefaultLeasePath, ErrGenerationSuperseded, HeaderSize, LTXError, SnapshotLevel, formatFilename, noopLogger, parseFilename, unmarshalHeader, } from '@platformatic/sqlite-js-stream';
|
|
37
37
|
import { isConditionalConflict, isNotFound, isPreconditionFailed, readBody } from "./util.js";
|
|
38
38
|
/** The strategy identifier reported by {@link S3ReplicaClient.type}. */
|
|
39
39
|
export const ReplicaClientType = 's3';
|
|
@@ -52,6 +52,31 @@ export function isFenced(fences, generation, maxTXID) {
|
|
|
52
52
|
}
|
|
53
53
|
return true;
|
|
54
54
|
}
|
|
55
|
+
/** Matches the key of an LTX file, capturing the prefix of its replica. */
|
|
56
|
+
const LTX_KEY_RE = /^(.+)\/ltx\/\d+\/[^/]+$/;
|
|
57
|
+
/** Returns the prefix of the replica a key belongs to, or null if none. */
|
|
58
|
+
function replicaOfKey(key) {
|
|
59
|
+
for (const name of [GenerationObjectName, DefaultLeasePath]) {
|
|
60
|
+
if (key.endsWith(`/${name}`))
|
|
61
|
+
return key.slice(0, -(name.length + 1));
|
|
62
|
+
}
|
|
63
|
+
return LTX_KEY_RE.exec(key)?.[1] ?? null;
|
|
64
|
+
}
|
|
65
|
+
/** Builds an S3 client from connection options, unless one is injected. */
|
|
66
|
+
function createClient(opts) {
|
|
67
|
+
return (opts.client ??
|
|
68
|
+
new S3Client({
|
|
69
|
+
region: opts.region,
|
|
70
|
+
endpoint: opts.endpoint,
|
|
71
|
+
credentials: opts.accessKeyId
|
|
72
|
+
? {
|
|
73
|
+
accessKeyId: opts.accessKeyId,
|
|
74
|
+
secretAccessKey: opts.secretAccessKey ?? '',
|
|
75
|
+
sessionToken: opts.sessionToken,
|
|
76
|
+
}
|
|
77
|
+
: undefined,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
55
80
|
/** Retries of a conditional write that keeps conflicting with concurrent ones. */
|
|
56
81
|
const MAX_CONDITIONAL_ATTEMPTS = 10;
|
|
57
82
|
/** HeadObject requests in flight while resolving the generation of listed files. */
|
|
@@ -133,19 +158,7 @@ export class S3ReplicaClient {
|
|
|
133
158
|
this.bucket = opts.bucket;
|
|
134
159
|
this.prefix = normalizePrefix(opts.prefix ?? '');
|
|
135
160
|
this.generation = opts.generation ?? null;
|
|
136
|
-
this.client =
|
|
137
|
-
opts.client ??
|
|
138
|
-
new S3Client({
|
|
139
|
-
region: opts.region,
|
|
140
|
-
endpoint: opts.endpoint,
|
|
141
|
-
credentials: opts.accessKeyId
|
|
142
|
-
? {
|
|
143
|
-
accessKeyId: opts.accessKeyId,
|
|
144
|
-
secretAccessKey: opts.secretAccessKey ?? '',
|
|
145
|
-
sessionToken: opts.sessionToken,
|
|
146
|
-
}
|
|
147
|
-
: undefined,
|
|
148
|
-
});
|
|
161
|
+
this.client = createClient(opts);
|
|
149
162
|
}
|
|
150
163
|
type() {
|
|
151
164
|
return ReplicaClientType;
|
|
@@ -643,6 +656,113 @@ export class S3ReplicaClient {
|
|
|
643
656
|
}));
|
|
644
657
|
}
|
|
645
658
|
}
|
|
659
|
+
/**
|
|
660
|
+
* Deletes every replica under a prefix: every object whose key starts with
|
|
661
|
+
* `prefix/`, which covers the replica at `prefix` and every replica under
|
|
662
|
+
* it, generation objects (`generation.json`) and lease objects included
|
|
663
|
+
* (unlike {@link deleteAll}, which keeps the generation). Resolves with the
|
|
664
|
+
* sorted prefixes of the deleted replicas: those with an LTX file, a
|
|
665
|
+
* generation object or a lease object.
|
|
666
|
+
*
|
|
667
|
+
* The generation objects are deleted last, so former writers stay fenced
|
|
668
|
+
* while the files are deleted. Once they are gone, nothing fences those
|
|
669
|
+
* writers anymore: a writer that is still running can upload again and
|
|
670
|
+
* create the replica anew. Stop the writers first.
|
|
671
|
+
*
|
|
672
|
+
* With {@link S3DeletePrefixOptions.generation}, the deletion is fenced as
|
|
673
|
+
* the deletions of a fenced client are, on a best-effort basis: the
|
|
674
|
+
* generation object of each replica is read before deleting its first
|
|
675
|
+
* object, and again before deleting the generation objects, and the
|
|
676
|
+
* deletion fails with {@link ErrGenerationSuperseded} if it holds a higher
|
|
677
|
+
* generation. S3 cannot check one object and delete another atomically, so
|
|
678
|
+
* a generation registered between a check and a `DeleteObjects` request
|
|
679
|
+
* does not stop it, and the replicas already processed stay deleted.
|
|
680
|
+
*/
|
|
681
|
+
static async deletePrefix(opts) {
|
|
682
|
+
if (!opts.bucket) {
|
|
683
|
+
throw new Error('S3 replica client: bucket is required');
|
|
684
|
+
}
|
|
685
|
+
const prefix = normalizePrefix(opts.prefix ?? '');
|
|
686
|
+
if (prefix === '') {
|
|
687
|
+
throw new Error('S3 replica client: deletePrefix requires a non-empty prefix');
|
|
688
|
+
}
|
|
689
|
+
const bucket = opts.bucket;
|
|
690
|
+
const generation = opts.generation ?? null;
|
|
691
|
+
const ownClient = opts.client === undefined;
|
|
692
|
+
const client = createClient(opts);
|
|
693
|
+
const replicas = new Set();
|
|
694
|
+
const checked = new Set();
|
|
695
|
+
const generationKeys = [];
|
|
696
|
+
let pending = [];
|
|
697
|
+
const assertReplica = async (replica) => {
|
|
698
|
+
if (generation === null)
|
|
699
|
+
return;
|
|
700
|
+
let res;
|
|
701
|
+
try {
|
|
702
|
+
res = await client.send(new GetObjectCommand({ Bucket: bucket, Key: `${replica}/${GenerationObjectName}` }));
|
|
703
|
+
}
|
|
704
|
+
catch (err) {
|
|
705
|
+
if (isNotFound(err))
|
|
706
|
+
return;
|
|
707
|
+
throw err;
|
|
708
|
+
}
|
|
709
|
+
const stored = decodeGenerationState(await readBody(res.Body)).generation;
|
|
710
|
+
if (stored > generation)
|
|
711
|
+
throw new ErrGenerationSuperseded(stored);
|
|
712
|
+
};
|
|
713
|
+
const flush = async (objects) => {
|
|
714
|
+
for (let i = 0; i < objects.length; i += 1000) {
|
|
715
|
+
const res = await client.send(new DeleteObjectsCommand({
|
|
716
|
+
Bucket: bucket,
|
|
717
|
+
Delete: { Objects: objects.slice(i, i + 1000), Quiet: true },
|
|
718
|
+
}));
|
|
719
|
+
const failed = (res?.Errors ?? []);
|
|
720
|
+
if (failed.length > 0) {
|
|
721
|
+
const e = failed[0];
|
|
722
|
+
throw new Error(`S3 replica client: failed to delete ${e.Key}: ${e.Code ?? ''} ${e.Message ?? ''}`.trim());
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
try {
|
|
727
|
+
let token;
|
|
728
|
+
do {
|
|
729
|
+
const res = await client.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: `${prefix}/`, ContinuationToken: token }));
|
|
730
|
+
for (const obj of (res.Contents ?? [])) {
|
|
731
|
+
const key = obj.Key;
|
|
732
|
+
if (!key)
|
|
733
|
+
continue;
|
|
734
|
+
const replica = replicaOfKey(key);
|
|
735
|
+
if (replica !== null) {
|
|
736
|
+
replicas.add(replica);
|
|
737
|
+
if (!checked.has(replica)) {
|
|
738
|
+
await assertReplica(replica);
|
|
739
|
+
checked.add(replica);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (key.endsWith(`/${GenerationObjectName}`)) {
|
|
743
|
+
generationKeys.push(key);
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
pending.push({ Key: key });
|
|
747
|
+
if (pending.length >= 1000) {
|
|
748
|
+
await flush(pending);
|
|
749
|
+
pending = [];
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
token = res.IsTruncated ? (res.NextContinuationToken ?? undefined) : undefined;
|
|
753
|
+
} while (token);
|
|
754
|
+
await flush(pending);
|
|
755
|
+
for (const key of generationKeys) {
|
|
756
|
+
await assertReplica(key.slice(0, -(GenerationObjectName.length + 1)));
|
|
757
|
+
}
|
|
758
|
+
await flush(generationKeys.map((Key) => ({ Key })));
|
|
759
|
+
}
|
|
760
|
+
finally {
|
|
761
|
+
if (ownClient)
|
|
762
|
+
client.destroy();
|
|
763
|
+
}
|
|
764
|
+
return [...replicas].sort();
|
|
765
|
+
}
|
|
646
766
|
/**
|
|
647
767
|
* Deletes every object under the prefix except the generation object, so a
|
|
648
768
|
* superseded writer stays fenced. A fenced client checks its generation
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platformatic/sqlite-js-stream-s3",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Amazon S3 upload strategy (replica client) for @platformatic/sqlite-js-stream",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@aws-sdk/client-s3": "^3.1135.0",
|
|
40
|
-
"@platformatic/sqlite-js-stream": "0.
|
|
40
|
+
"@platformatic/sqlite-js-stream": "0.4.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "^26.0.0",
|
package/src/index.ts
CHANGED
package/src/replica-client.ts
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
} from '@aws-sdk/client-s3';
|
|
50
50
|
|
|
51
51
|
import {
|
|
52
|
+
DefaultLeasePath,
|
|
52
53
|
ErrGenerationSuperseded,
|
|
53
54
|
HeaderSize,
|
|
54
55
|
LTXError,
|
|
@@ -102,6 +103,34 @@ export interface S3ReplicaClientOptions {
|
|
|
102
103
|
generation?: bigint | null;
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
/** Configuration for {@link S3ReplicaClient.deletePrefix}. */
|
|
107
|
+
export interface S3DeletePrefixOptions {
|
|
108
|
+
/** S3 bucket name. Required. */
|
|
109
|
+
bucket: string;
|
|
110
|
+
/**
|
|
111
|
+
* Prefix to delete: the replica at this prefix and every replica under
|
|
112
|
+
* `prefix/` (`a` covers `a` and `a/b`, not `ab`). Leading/trailing slashes
|
|
113
|
+
* are normalized away. Must not be empty.
|
|
114
|
+
*/
|
|
115
|
+
prefix: string;
|
|
116
|
+
/** AWS region. Defaults to the AWS SDK region resolution chain. */
|
|
117
|
+
region?: string;
|
|
118
|
+
/** Custom endpoint URL (e.g. for MinIO / LocalStack). */
|
|
119
|
+
endpoint?: string;
|
|
120
|
+
accessKeyId?: string;
|
|
121
|
+
secretAccessKey?: string;
|
|
122
|
+
sessionToken?: string;
|
|
123
|
+
/** Inject a preconfigured client. */
|
|
124
|
+
client?: S3ClientLike;
|
|
125
|
+
/**
|
|
126
|
+
* Fences the deletion: it fails with {@link ErrGenerationSuperseded} if a
|
|
127
|
+
* replica under the prefix has a higher generation, checked before deleting
|
|
128
|
+
* any of its objects (best effort, see {@link S3ReplicaClient.deletePrefix}).
|
|
129
|
+
* `null` (the default) deletes unconditionally.
|
|
130
|
+
*/
|
|
131
|
+
generation?: bigint | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
105
134
|
/** Name of the generation object, relative to the replica prefix. */
|
|
106
135
|
export const GenerationObjectName = 'generation.json';
|
|
107
136
|
|
|
@@ -136,6 +165,42 @@ export function isFenced(fences: readonly GenerationFence[], generation: bigint,
|
|
|
136
165
|
return true;
|
|
137
166
|
}
|
|
138
167
|
|
|
168
|
+
/** Matches the key of an LTX file, capturing the prefix of its replica. */
|
|
169
|
+
const LTX_KEY_RE = /^(.+)\/ltx\/\d+\/[^/]+$/;
|
|
170
|
+
|
|
171
|
+
/** Returns the prefix of the replica a key belongs to, or null if none. */
|
|
172
|
+
function replicaOfKey(key: string): string | null {
|
|
173
|
+
for (const name of [GenerationObjectName, DefaultLeasePath]) {
|
|
174
|
+
if (key.endsWith(`/${name}`)) return key.slice(0, -(name.length + 1));
|
|
175
|
+
}
|
|
176
|
+
return LTX_KEY_RE.exec(key)?.[1] ?? null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Builds an S3 client from connection options, unless one is injected. */
|
|
180
|
+
function createClient(opts: {
|
|
181
|
+
client?: S3ClientLike;
|
|
182
|
+
region?: string;
|
|
183
|
+
endpoint?: string;
|
|
184
|
+
accessKeyId?: string;
|
|
185
|
+
secretAccessKey?: string;
|
|
186
|
+
sessionToken?: string;
|
|
187
|
+
}): S3ClientLike {
|
|
188
|
+
return (
|
|
189
|
+
opts.client ??
|
|
190
|
+
new S3Client({
|
|
191
|
+
region: opts.region,
|
|
192
|
+
endpoint: opts.endpoint,
|
|
193
|
+
credentials: opts.accessKeyId
|
|
194
|
+
? {
|
|
195
|
+
accessKeyId: opts.accessKeyId,
|
|
196
|
+
secretAccessKey: opts.secretAccessKey ?? '',
|
|
197
|
+
sessionToken: opts.sessionToken,
|
|
198
|
+
}
|
|
199
|
+
: undefined,
|
|
200
|
+
})
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
139
204
|
/** Retries of a conditional write that keeps conflicting with concurrent ones. */
|
|
140
205
|
const MAX_CONDITIONAL_ATTEMPTS = 10;
|
|
141
206
|
|
|
@@ -234,19 +299,7 @@ export class S3ReplicaClient implements ReplicaClient {
|
|
|
234
299
|
this.bucket = opts.bucket;
|
|
235
300
|
this.prefix = normalizePrefix(opts.prefix ?? '');
|
|
236
301
|
this.generation = opts.generation ?? null;
|
|
237
|
-
this.client =
|
|
238
|
-
opts.client ??
|
|
239
|
-
new S3Client({
|
|
240
|
-
region: opts.region,
|
|
241
|
-
endpoint: opts.endpoint,
|
|
242
|
-
credentials: opts.accessKeyId
|
|
243
|
-
? {
|
|
244
|
-
accessKeyId: opts.accessKeyId,
|
|
245
|
-
secretAccessKey: opts.secretAccessKey ?? '',
|
|
246
|
-
sessionToken: opts.sessionToken,
|
|
247
|
-
}
|
|
248
|
-
: undefined,
|
|
249
|
-
});
|
|
302
|
+
this.client = createClient(opts);
|
|
250
303
|
}
|
|
251
304
|
|
|
252
305
|
type(): string {
|
|
@@ -776,6 +829,118 @@ export class S3ReplicaClient implements ReplicaClient {
|
|
|
776
829
|
}
|
|
777
830
|
}
|
|
778
831
|
|
|
832
|
+
/**
|
|
833
|
+
* Deletes every replica under a prefix: every object whose key starts with
|
|
834
|
+
* `prefix/`, which covers the replica at `prefix` and every replica under
|
|
835
|
+
* it, generation objects (`generation.json`) and lease objects included
|
|
836
|
+
* (unlike {@link deleteAll}, which keeps the generation). Resolves with the
|
|
837
|
+
* sorted prefixes of the deleted replicas: those with an LTX file, a
|
|
838
|
+
* generation object or a lease object.
|
|
839
|
+
*
|
|
840
|
+
* The generation objects are deleted last, so former writers stay fenced
|
|
841
|
+
* while the files are deleted. Once they are gone, nothing fences those
|
|
842
|
+
* writers anymore: a writer that is still running can upload again and
|
|
843
|
+
* create the replica anew. Stop the writers first.
|
|
844
|
+
*
|
|
845
|
+
* With {@link S3DeletePrefixOptions.generation}, the deletion is fenced as
|
|
846
|
+
* the deletions of a fenced client are, on a best-effort basis: the
|
|
847
|
+
* generation object of each replica is read before deleting its first
|
|
848
|
+
* object, and again before deleting the generation objects, and the
|
|
849
|
+
* deletion fails with {@link ErrGenerationSuperseded} if it holds a higher
|
|
850
|
+
* generation. S3 cannot check one object and delete another atomically, so
|
|
851
|
+
* a generation registered between a check and a `DeleteObjects` request
|
|
852
|
+
* does not stop it, and the replicas already processed stay deleted.
|
|
853
|
+
*/
|
|
854
|
+
static async deletePrefix(opts: S3DeletePrefixOptions): Promise<string[]> {
|
|
855
|
+
if (!opts.bucket) {
|
|
856
|
+
throw new Error('S3 replica client: bucket is required');
|
|
857
|
+
}
|
|
858
|
+
const prefix = normalizePrefix(opts.prefix ?? '');
|
|
859
|
+
if (prefix === '') {
|
|
860
|
+
throw new Error('S3 replica client: deletePrefix requires a non-empty prefix');
|
|
861
|
+
}
|
|
862
|
+
const bucket = opts.bucket;
|
|
863
|
+
const generation = opts.generation ?? null;
|
|
864
|
+
const ownClient = opts.client === undefined;
|
|
865
|
+
const client = createClient(opts);
|
|
866
|
+
|
|
867
|
+
const replicas = new Set<string>();
|
|
868
|
+
const checked = new Set<string>();
|
|
869
|
+
const generationKeys: string[] = [];
|
|
870
|
+
let pending: Array<{ Key: string }> = [];
|
|
871
|
+
|
|
872
|
+
const assertReplica = async (replica: string): Promise<void> => {
|
|
873
|
+
if (generation === null) return;
|
|
874
|
+
let res;
|
|
875
|
+
try {
|
|
876
|
+
res = await client.send(
|
|
877
|
+
new GetObjectCommand({ Bucket: bucket, Key: `${replica}/${GenerationObjectName}` }),
|
|
878
|
+
);
|
|
879
|
+
} catch (err) {
|
|
880
|
+
if (isNotFound(err)) return;
|
|
881
|
+
throw err;
|
|
882
|
+
}
|
|
883
|
+
const stored = decodeGenerationState(await readBody(res.Body)).generation;
|
|
884
|
+
if (stored > generation) throw new ErrGenerationSuperseded(stored);
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
const flush = async (objects: Array<{ Key: string }>): Promise<void> => {
|
|
888
|
+
for (let i = 0; i < objects.length; i += 1000) {
|
|
889
|
+
const res = await client.send(
|
|
890
|
+
new DeleteObjectsCommand({
|
|
891
|
+
Bucket: bucket,
|
|
892
|
+
Delete: { Objects: objects.slice(i, i + 1000), Quiet: true },
|
|
893
|
+
}),
|
|
894
|
+
);
|
|
895
|
+
const failed = (res?.Errors ?? []) as Array<{ Key?: string; Code?: string; Message?: string }>;
|
|
896
|
+
if (failed.length > 0) {
|
|
897
|
+
const e = failed[0];
|
|
898
|
+
throw new Error(`S3 replica client: failed to delete ${e.Key}: ${e.Code ?? ''} ${e.Message ?? ''}`.trim());
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
try {
|
|
904
|
+
let token: string | undefined;
|
|
905
|
+
do {
|
|
906
|
+
const res = await client.send(
|
|
907
|
+
new ListObjectsV2Command({ Bucket: bucket, Prefix: `${prefix}/`, ContinuationToken: token }),
|
|
908
|
+
);
|
|
909
|
+
for (const obj of (res.Contents ?? []) as Array<{ Key?: string }>) {
|
|
910
|
+
const key = obj.Key;
|
|
911
|
+
if (!key) continue;
|
|
912
|
+
const replica = replicaOfKey(key);
|
|
913
|
+
if (replica !== null) {
|
|
914
|
+
replicas.add(replica);
|
|
915
|
+
if (!checked.has(replica)) {
|
|
916
|
+
await assertReplica(replica);
|
|
917
|
+
checked.add(replica);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
if (key.endsWith(`/${GenerationObjectName}`)) {
|
|
921
|
+
generationKeys.push(key);
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
pending.push({ Key: key });
|
|
925
|
+
if (pending.length >= 1000) {
|
|
926
|
+
await flush(pending);
|
|
927
|
+
pending = [];
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
token = res.IsTruncated ? (res.NextContinuationToken ?? undefined) : undefined;
|
|
931
|
+
} while (token);
|
|
932
|
+
await flush(pending);
|
|
933
|
+
|
|
934
|
+
for (const key of generationKeys) {
|
|
935
|
+
await assertReplica(key.slice(0, -(GenerationObjectName.length + 1)));
|
|
936
|
+
}
|
|
937
|
+
await flush(generationKeys.map((Key) => ({ Key })));
|
|
938
|
+
} finally {
|
|
939
|
+
if (ownClient) (client as S3Client).destroy();
|
|
940
|
+
}
|
|
941
|
+
return [...replicas].sort();
|
|
942
|
+
}
|
|
943
|
+
|
|
779
944
|
/**
|
|
780
945
|
* Deletes every object under the prefix except the generation object, so a
|
|
781
946
|
* superseded writer stays fenced. A fenced client checks its generation
|