@platformatic/sqlite-js-stream-s3 0.2.1 → 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/index.js +1 -1
- package/dist/leaser.d.ts.map +1 -1
- package/dist/leaser.js +1 -55
- package/dist/replica-client.d.ts +168 -0
- package/dist/replica-client.d.ts.map +1 -1
- package/dist/replica-client.js +537 -43
- package/dist/util.d.ts +14 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +68 -0
- package/package.json +3 -3
- package/src/index.ts +6 -0
- package/src/leaser.ts +1 -54
- package/src/replica-client.ts +629 -44
- package/src/util.ts +68 -0
package/src/replica-client.ts
CHANGED
|
@@ -10,6 +10,26 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Creation timestamps are preserved on upload as the `ltx-timestamp` object
|
|
12
12
|
* metadata; listing falls back to the object's `LastModified`.
|
|
13
|
+
*
|
|
14
|
+
* The client also supports *fenced writes* (see {@link S3ReplicaClient.registerGeneration}).
|
|
15
|
+
* S3 cannot check one object and write another in a single request, so fencing
|
|
16
|
+
* is a protocol built from conditional writes:
|
|
17
|
+
*
|
|
18
|
+
* - `{prefix}/generation.json` holds the current generation and a list of
|
|
19
|
+
* *fences*. A fence `(generation, txid)` records the latest TXID of the
|
|
20
|
+
* replica right after `generation` was registered. Registering is a
|
|
21
|
+
* conditional write (`If-Match` / `If-None-Match: *`) that only raises the
|
|
22
|
+
* generation; the fence is then added with another conditional write.
|
|
23
|
+
* - LTX files of a fenced client are written with `If-None-Match: *`, so a TXID
|
|
24
|
+
* range is written once per level, and carry their generation in the
|
|
25
|
+
* `ltx-generation` metadata. A file with no `ltx-generation` is generation 0.
|
|
26
|
+
* - Listings ignore a file whose generation is lower than a fence's generation
|
|
27
|
+
* and whose `maxTXID` is above that fence's TXID: it was written by a
|
|
28
|
+
* superseded writer after the new one read the replica.
|
|
29
|
+
* - After each upload, a fenced client re-reads the generation object and fails
|
|
30
|
+
* with {@link ErrGenerationSuperseded} if a higher generation is registered.
|
|
31
|
+
* An upload that resolves was therefore written while its generation was the
|
|
32
|
+
* latest, and every later generation sees it.
|
|
13
33
|
*/
|
|
14
34
|
|
|
15
35
|
import { Readable } from 'node:stream';
|
|
@@ -29,8 +49,11 @@ import {
|
|
|
29
49
|
} from '@aws-sdk/client-s3';
|
|
30
50
|
|
|
31
51
|
import {
|
|
52
|
+
DefaultLeasePath,
|
|
53
|
+
ErrGenerationSuperseded,
|
|
32
54
|
HeaderSize,
|
|
33
55
|
LTXError,
|
|
56
|
+
SnapshotLevel,
|
|
34
57
|
formatFilename,
|
|
35
58
|
noopLogger,
|
|
36
59
|
parseFilename,
|
|
@@ -43,6 +66,8 @@ import {
|
|
|
43
66
|
type WriteLTXFileOptions,
|
|
44
67
|
} from '@platformatic/sqlite-js-stream';
|
|
45
68
|
|
|
69
|
+
import { isConditionalConflict, isNotFound, isPreconditionFailed, readBody } from './util.ts';
|
|
70
|
+
|
|
46
71
|
/** The strategy identifier reported by {@link S3ReplicaClient.type}. */
|
|
47
72
|
export const ReplicaClientType = 's3';
|
|
48
73
|
|
|
@@ -69,8 +94,171 @@ export interface S3ReplicaClientOptions {
|
|
|
69
94
|
sessionToken?: string;
|
|
70
95
|
/** Inject a preconfigured client. Mostly for tests. */
|
|
71
96
|
client?: S3ClientLike;
|
|
97
|
+
/**
|
|
98
|
+
* Fenced-write generation, or `null` (the default) to disable fencing. Set
|
|
99
|
+
* by {@link S3ReplicaClient.registerGeneration}; when set, uploads and
|
|
100
|
+
* deletions fail with {@link ErrGenerationSuperseded} once a higher
|
|
101
|
+
* generation is registered.
|
|
102
|
+
*/
|
|
103
|
+
generation?: bigint | null;
|
|
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
|
+
|
|
134
|
+
/** Name of the generation object, relative to the replica prefix. */
|
|
135
|
+
export const GenerationObjectName = 'generation.json';
|
|
136
|
+
|
|
137
|
+
/** Object metadata key carrying the generation of a fenced LTX upload. */
|
|
138
|
+
export const GenerationMetadataKey = 'ltx-generation';
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A fence: `generation` was registered when the latest TXID of the replica was
|
|
142
|
+
* `txid`. Files of lower generations with a `maxTXID` above `txid` are ignored.
|
|
143
|
+
*/
|
|
144
|
+
export interface GenerationFence {
|
|
145
|
+
generation: bigint;
|
|
146
|
+
txid: bigint;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Contents of the generation object. */
|
|
150
|
+
export interface GenerationState {
|
|
151
|
+
/** The highest registered generation. */
|
|
152
|
+
generation: bigint;
|
|
153
|
+
/** Fences, in registration order (one per generation that completed registration). */
|
|
154
|
+
fences: GenerationFence[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Returns true if a file of `generation` ending at `maxTXID` is part of the
|
|
159
|
+
* replica: no fence of a higher generation was recorded below `maxTXID`.
|
|
160
|
+
*/
|
|
161
|
+
export function isFenced(fences: readonly GenerationFence[], generation: bigint, maxTXID: bigint): boolean {
|
|
162
|
+
for (const f of fences) {
|
|
163
|
+
if (f.generation > generation && maxTXID > f.txid) return false;
|
|
164
|
+
}
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
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
|
+
|
|
204
|
+
/** Retries of a conditional write that keeps conflicting with concurrent ones. */
|
|
205
|
+
const MAX_CONDITIONAL_ATTEMPTS = 10;
|
|
206
|
+
|
|
207
|
+
/** HeadObject requests in flight while resolving the generation of listed files. */
|
|
208
|
+
const HEAD_CONCURRENCY = 16;
|
|
209
|
+
|
|
210
|
+
/** Cached generations of listed objects (by key and ETag). */
|
|
211
|
+
const GENERATION_CACHE_SIZE = 10_000;
|
|
212
|
+
|
|
213
|
+
/** Parses the `ltx-generation` metadata (0 when absent or invalid). */
|
|
214
|
+
function metadataGeneration(metadata: Record<string, string> | undefined): bigint {
|
|
215
|
+
const v = metadata?.[GenerationMetadataKey];
|
|
216
|
+
if (v === undefined || !/^\d+$/.test(v)) return 0n;
|
|
217
|
+
return BigInt(v);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Serializes a generation state (bigints as decimal strings). */
|
|
221
|
+
function encodeGenerationState(state: GenerationState): Uint8Array {
|
|
222
|
+
return new TextEncoder().encode(
|
|
223
|
+
JSON.stringify({
|
|
224
|
+
generation: state.generation.toString(),
|
|
225
|
+
fences: state.fences.map((f) => ({ generation: f.generation.toString(), txid: f.txid.toString() })),
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Parses a generation object body. */
|
|
231
|
+
function decodeGenerationState(data: Uint8Array): GenerationState {
|
|
232
|
+
const parsed = JSON.parse(new TextDecoder().decode(data)) as {
|
|
233
|
+
generation?: string;
|
|
234
|
+
fences?: Array<{ generation?: string; txid?: string }>;
|
|
235
|
+
};
|
|
236
|
+
return {
|
|
237
|
+
generation: BigInt(parsed.generation ?? '0'),
|
|
238
|
+
fences: (parsed.fences ?? []).map((f) => ({
|
|
239
|
+
generation: BigInt(f.generation ?? '0'),
|
|
240
|
+
txid: BigInt(f.txid ?? '0'),
|
|
241
|
+
})),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Runs `fn` over `items` with at most `limit` calls in flight, preserving order. */
|
|
246
|
+
async function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
247
|
+
const out = new Array<R>(items.length);
|
|
248
|
+
let next = 0;
|
|
249
|
+
const worker = async (): Promise<void> => {
|
|
250
|
+
while (next < items.length) {
|
|
251
|
+
const i = next++;
|
|
252
|
+
out[i] = await fn(items[i]);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
256
|
+
return out;
|
|
72
257
|
}
|
|
73
258
|
|
|
259
|
+
/** Conditional headers of a write. */
|
|
260
|
+
type WriteCondition = { IfNoneMatch: '*' } | { IfMatch: string } | Record<string, never>;
|
|
261
|
+
|
|
74
262
|
/** Normalized base prefix (no leading/trailing slash, `''` when empty). */
|
|
75
263
|
function normalizePrefix(prefix: string): string {
|
|
76
264
|
return prefix.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
@@ -98,6 +286,11 @@ export class S3ReplicaClient implements ReplicaClient {
|
|
|
98
286
|
readonly prefix: string;
|
|
99
287
|
private client: S3ClientLike;
|
|
100
288
|
private logger: Logger = noopLogger;
|
|
289
|
+
private generation: bigint | null;
|
|
290
|
+
/** The higher generation that superseded this client, once observed. */
|
|
291
|
+
private supersededBy: bigint | null = null;
|
|
292
|
+
/** Generation of listed objects, keyed by `key` + ETag (objects are immutable). */
|
|
293
|
+
private readonly objectGenerations = new Map<string, bigint>();
|
|
101
294
|
|
|
102
295
|
constructor(opts: S3ReplicaClientOptions) {
|
|
103
296
|
if (!opts.bucket) {
|
|
@@ -105,19 +298,8 @@ export class S3ReplicaClient implements ReplicaClient {
|
|
|
105
298
|
}
|
|
106
299
|
this.bucket = opts.bucket;
|
|
107
300
|
this.prefix = normalizePrefix(opts.prefix ?? '');
|
|
108
|
-
this.
|
|
109
|
-
|
|
110
|
-
new S3Client({
|
|
111
|
-
region: opts.region,
|
|
112
|
-
endpoint: opts.endpoint,
|
|
113
|
-
credentials: opts.accessKeyId
|
|
114
|
-
? {
|
|
115
|
-
accessKeyId: opts.accessKeyId,
|
|
116
|
-
secretAccessKey: opts.secretAccessKey ?? '',
|
|
117
|
-
sessionToken: opts.sessionToken,
|
|
118
|
-
}
|
|
119
|
-
: undefined,
|
|
120
|
-
});
|
|
301
|
+
this.generation = opts.generation ?? null;
|
|
302
|
+
this.client = createClient(opts);
|
|
121
303
|
}
|
|
122
304
|
|
|
123
305
|
type(): string {
|
|
@@ -152,8 +334,166 @@ export class S3ReplicaClient implements ReplicaClient {
|
|
|
152
334
|
return `${this.levelPrefix(level)}${formatFilename(minTXID, maxTXID)}`;
|
|
153
335
|
}
|
|
154
336
|
|
|
337
|
+
/** Returns the key of the generation object. */
|
|
338
|
+
private generationKey(): string {
|
|
339
|
+
return `${this.prefix ? `${this.prefix}/` : ''}${GenerationObjectName}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Current fenced-write generation, or `null` when fencing is disabled. */
|
|
343
|
+
getGeneration(): bigint | null {
|
|
344
|
+
return this.generation;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Reads the generation object, or returns `null` if fencing was never used. */
|
|
348
|
+
async getGenerationState(): Promise<GenerationState | null> {
|
|
349
|
+
return (await this.readGenerationObject())?.state ?? null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private async readGenerationObject(): Promise<{ state: GenerationState; etag: string } | null> {
|
|
353
|
+
let res;
|
|
354
|
+
try {
|
|
355
|
+
res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: this.generationKey() }));
|
|
356
|
+
} catch (err) {
|
|
357
|
+
if (isNotFound(err)) return null;
|
|
358
|
+
throw err;
|
|
359
|
+
}
|
|
360
|
+
const state = decodeGenerationState(await readBody(res.Body));
|
|
361
|
+
return { state, etag: String(res.ETag ?? '') };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Writes the generation object if it was not changed since it was read with
|
|
366
|
+
* `etag` (`null`: if it does not exist). Returns false on a lost race.
|
|
367
|
+
*/
|
|
368
|
+
private async writeGenerationObject(state: GenerationState, etag: string | null): Promise<boolean> {
|
|
369
|
+
try {
|
|
370
|
+
await this.client.send(
|
|
371
|
+
new PutObjectCommand({
|
|
372
|
+
Bucket: this.bucket,
|
|
373
|
+
Key: this.generationKey(),
|
|
374
|
+
Body: encodeGenerationState(state),
|
|
375
|
+
ContentType: 'application/json',
|
|
376
|
+
...(etag === null ? { IfNoneMatch: '*' } : { IfMatch: etag }),
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
379
|
+
return true;
|
|
380
|
+
} catch (err) {
|
|
381
|
+
if (isPreconditionFailed(err) || isConditionalConflict(err)) return false;
|
|
382
|
+
throw err;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Records that this client was superseded by `generation` and returns the error. */
|
|
387
|
+
private superseded(generation: bigint): ErrGenerationSuperseded {
|
|
388
|
+
this.supersededBy = generation;
|
|
389
|
+
return new ErrGenerationSuperseded(generation);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Fails with {@link ErrGenerationSuperseded} if a generation higher than this
|
|
394
|
+
* client's is registered. A no-op when fencing is disabled.
|
|
395
|
+
*/
|
|
396
|
+
private async assertGeneration(): Promise<void> {
|
|
397
|
+
if (this.generation === null) return;
|
|
398
|
+
if (this.supersededBy !== null) throw new ErrGenerationSuperseded(this.supersededBy);
|
|
399
|
+
const cur = await this.readGenerationObject();
|
|
400
|
+
if (cur !== null && cur.state.generation > this.generation) {
|
|
401
|
+
throw this.superseded(cur.state.generation);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Raises the replica's generation to `generation`, failing with
|
|
407
|
+
* {@link ErrGenerationSuperseded} if a higher one is already registered, and
|
|
408
|
+
* records a fence at the replica's latest TXID as read after the
|
|
409
|
+
* registration. From then on, files of lower generations above that TXID
|
|
410
|
+
* are ignored by every listing, and uploads and deletions of lower
|
|
411
|
+
* generations fail once they observe the new generation.
|
|
412
|
+
*
|
|
413
|
+
* Call it before restoring: the restore then sees every file that a former
|
|
414
|
+
* writer can still make part of the replica. Registering the current
|
|
415
|
+
* generation again is a no-op that keeps its fence.
|
|
416
|
+
*/
|
|
417
|
+
async registerGeneration(generation: bigint): Promise<void> {
|
|
418
|
+
// 1. Raise the generation (compare-and-swap on the generation object).
|
|
419
|
+
for (let attempt = 0; ; attempt++) {
|
|
420
|
+
if (attempt >= MAX_CONDITIONAL_ATTEMPTS) {
|
|
421
|
+
throw new Error('S3 replica client: too many conflicts registering the generation');
|
|
422
|
+
}
|
|
423
|
+
const cur = await this.readGenerationObject();
|
|
424
|
+
if (cur !== null && cur.state.generation > generation) {
|
|
425
|
+
throw new ErrGenerationSuperseded(cur.state.generation);
|
|
426
|
+
}
|
|
427
|
+
if (cur !== null && cur.state.generation === generation) break;
|
|
428
|
+
const next = { generation, fences: cur?.state.fences ?? [] };
|
|
429
|
+
if (await this.writeGenerationObject(next, cur?.etag ?? null)) break;
|
|
430
|
+
}
|
|
431
|
+
this.generation = generation;
|
|
432
|
+
this.supersededBy = null;
|
|
433
|
+
|
|
434
|
+
// 2. Read the latest TXID of the replica. Files of lower generations that
|
|
435
|
+
// land from now on and end above it are not part of the replica.
|
|
436
|
+
let txid = 0n;
|
|
437
|
+
for (let level = 0; level <= SnapshotLevel; level++) {
|
|
438
|
+
for await (const info of this.ltxFiles({ level, seek: 0n, useMetadata: false })) {
|
|
439
|
+
if (info.maxTXID > txid) txid = info.maxTXID;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 3. Record the fence, unless a higher generation registered meanwhile.
|
|
444
|
+
for (let attempt = 0; ; attempt++) {
|
|
445
|
+
if (attempt >= MAX_CONDITIONAL_ATTEMPTS) {
|
|
446
|
+
throw new Error('S3 replica client: too many conflicts recording the generation fence');
|
|
447
|
+
}
|
|
448
|
+
const cur = await this.readGenerationObject();
|
|
449
|
+
const stored = cur?.state.generation ?? generation;
|
|
450
|
+
if (stored > generation) throw this.superseded(stored);
|
|
451
|
+
const fences = cur?.state.fences ?? [];
|
|
452
|
+
if (fences.some((f) => f.generation === generation)) break;
|
|
453
|
+
const next = { generation, fences: [...fences, { generation, txid }] };
|
|
454
|
+
if (await this.writeGenerationObject(next, cur?.etag ?? null)) break;
|
|
455
|
+
}
|
|
456
|
+
this.logger.debug('registered generation', { generation, txid });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Returns the generation of an object (from `ltx-generation`), or null if it is gone. */
|
|
460
|
+
private async objectGeneration(key: string, etag: string | undefined): Promise<bigint | null> {
|
|
461
|
+
const cacheKey = etag ? `${key}\n${etag}` : null;
|
|
462
|
+
if (cacheKey !== null) {
|
|
463
|
+
const cached = this.objectGenerations.get(cacheKey);
|
|
464
|
+
if (cached !== undefined) return cached;
|
|
465
|
+
}
|
|
466
|
+
let head;
|
|
467
|
+
try {
|
|
468
|
+
head = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
469
|
+
} catch (err) {
|
|
470
|
+
if (isNotFound(err)) return null;
|
|
471
|
+
throw err;
|
|
472
|
+
}
|
|
473
|
+
const generation = metadataGeneration(head.Metadata);
|
|
474
|
+
this.rememberGeneration(key, head.ETag ?? etag, generation);
|
|
475
|
+
return generation;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private rememberGeneration(key: string, etag: string | undefined, generation: bigint): void {
|
|
479
|
+
if (!etag) return;
|
|
480
|
+
if (this.objectGenerations.size >= GENERATION_CACHE_SIZE) this.objectGenerations.clear();
|
|
481
|
+
this.objectGenerations.set(`${key}\n${etag}`, generation);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Lists the LTX files of a level. When the replica has fences, files ending
|
|
486
|
+
* above the lowest fence need their generation (a HeadObject request, cached
|
|
487
|
+
* per key and ETag), and those written by a superseded writer are skipped.
|
|
488
|
+
*/
|
|
155
489
|
async *ltxFiles(opts: LTXFilesOptions): AsyncIterable<FileInfo> {
|
|
156
490
|
const prefix = this.levelPrefix(opts.level);
|
|
491
|
+
const fences = (await this.readGenerationObject())?.state.fences ?? [];
|
|
492
|
+
let lowestFence: bigint | null = null;
|
|
493
|
+
for (const f of fences) {
|
|
494
|
+
if (lowestFence === null || f.txid < lowestFence) lowestFence = f.txid;
|
|
495
|
+
}
|
|
496
|
+
|
|
157
497
|
let token: string | undefined;
|
|
158
498
|
do {
|
|
159
499
|
const res = await this.client.send(
|
|
@@ -163,6 +503,7 @@ export class S3ReplicaClient implements ReplicaClient {
|
|
|
163
503
|
ContinuationToken: token,
|
|
164
504
|
}),
|
|
165
505
|
);
|
|
506
|
+
const page: Array<{ info: FileInfo; key: string; etag: string | undefined }> = [];
|
|
166
507
|
for (const obj of res.Contents ?? []) {
|
|
167
508
|
if (!obj.Key) continue;
|
|
168
509
|
const name = obj.Key.slice(prefix.length);
|
|
@@ -174,13 +515,30 @@ export class S3ReplicaClient implements ReplicaClient {
|
|
|
174
515
|
continue; // skip non-conforming keys
|
|
175
516
|
}
|
|
176
517
|
if (minTXID < opts.seek) continue;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
518
|
+
page.push({
|
|
519
|
+
key: obj.Key,
|
|
520
|
+
etag: obj.ETag,
|
|
521
|
+
info: {
|
|
522
|
+
level: opts.level,
|
|
523
|
+
minTXID,
|
|
524
|
+
maxTXID,
|
|
525
|
+
size: obj.Size ?? 0,
|
|
526
|
+
createdAt: (obj.LastModified ?? new Date(0)).getTime(),
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
let visible: boolean[] = page.map(() => true);
|
|
532
|
+
if (lowestFence !== null) {
|
|
533
|
+
const fence = lowestFence;
|
|
534
|
+
visible = await mapLimit(page, HEAD_CONCURRENCY, async (p) => {
|
|
535
|
+
if (p.info.maxTXID <= fence) return true;
|
|
536
|
+
const generation = await this.objectGeneration(p.key, p.etag);
|
|
537
|
+
return generation !== null && isFenced(fences, generation, p.info.maxTXID);
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
for (let i = 0; i < page.length; i++) {
|
|
541
|
+
if (visible[i]) yield page[i].info;
|
|
184
542
|
}
|
|
185
543
|
token = res.IsTruncated ? (res.NextContinuationToken ?? undefined) : undefined;
|
|
186
544
|
} while (token);
|
|
@@ -228,6 +586,10 @@ export class S3ReplicaClient implements ReplicaClient {
|
|
|
228
586
|
|
|
229
587
|
async writeLtxFile(opts: WriteLTXFileOptions, body: AsyncIterable<Uint8Array | string>): Promise<FileInfo> {
|
|
230
588
|
const key = this.fileKey(opts.level, opts.minTXID, opts.maxTXID);
|
|
589
|
+
const generation = this.generation;
|
|
590
|
+
if (generation !== null && this.supersededBy !== null) {
|
|
591
|
+
throw new ErrGenerationSuperseded(this.supersededBy);
|
|
592
|
+
}
|
|
231
593
|
|
|
232
594
|
// Capture the LTX header (timestamp) from the leading bytes, and stream the
|
|
233
595
|
// rest. Small objects use a single PutObject; large objects (> 5 MiB) are
|
|
@@ -241,6 +603,7 @@ export class S3ReplicaClient implements ReplicaClient {
|
|
|
241
603
|
let size = 0;
|
|
242
604
|
|
|
243
605
|
let uploadId: string | null = null;
|
|
606
|
+
let completed = false;
|
|
244
607
|
let partNumber = 0;
|
|
245
608
|
const uploadedParts: Array<{ PartNumber: number; ETag: string }> = [];
|
|
246
609
|
|
|
@@ -258,6 +621,12 @@ export class S3ReplicaClient implements ReplicaClient {
|
|
|
258
621
|
uploadedParts.push({ PartNumber: partNumber, ETag: res.ETag ?? `etag-${partNumber}` });
|
|
259
622
|
};
|
|
260
623
|
|
|
624
|
+
const metadataFor = (timestamp: number): Record<string, string> => {
|
|
625
|
+
const metadata: Record<string, string> = { 'ltx-timestamp': String(timestamp) };
|
|
626
|
+
if (generation !== null) metadata[GenerationMetadataKey] = generation.toString();
|
|
627
|
+
return metadata;
|
|
628
|
+
};
|
|
629
|
+
|
|
261
630
|
try {
|
|
262
631
|
for await (const chunk of body) {
|
|
263
632
|
const bytes =
|
|
@@ -283,7 +652,7 @@ export class S3ReplicaClient implements ReplicaClient {
|
|
|
283
652
|
new CreateMultipartUploadCommand({
|
|
284
653
|
Bucket: this.bucket,
|
|
285
654
|
Key: key,
|
|
286
|
-
Metadata:
|
|
655
|
+
Metadata: metadataFor(Number(unmarshalHeader(headerBytes!).timestamp)),
|
|
287
656
|
}),
|
|
288
657
|
);
|
|
289
658
|
uploadId = res.UploadId;
|
|
@@ -301,42 +670,69 @@ export class S3ReplicaClient implements ReplicaClient {
|
|
|
301
670
|
throw new Error('write ltx file: input too short to contain a header');
|
|
302
671
|
}
|
|
303
672
|
const timestamp = Number(unmarshalHeader(headerBytes).timestamp);
|
|
304
|
-
const metadata = { 'ltx-timestamp': String(timestamp) };
|
|
305
673
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
)
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
674
|
+
// Multipart: upload any remaining bytes as the final part.
|
|
675
|
+
if (uploadId !== null && accLen > 0) {
|
|
676
|
+
await uploadPart(concatBytes(acc));
|
|
677
|
+
acc = [];
|
|
678
|
+
accLen = 0;
|
|
679
|
+
}
|
|
680
|
+
const buf = uploadId === null ? concatBytes(acc) : null;
|
|
681
|
+
|
|
682
|
+
// Creates the object: a single PutObject for small objects, otherwise
|
|
683
|
+
// completes the multipart upload.
|
|
684
|
+
const commit = (condition: WriteCondition): Promise<{ ETag?: string }> => {
|
|
685
|
+
if (buf !== null) {
|
|
686
|
+
return this.client.send(
|
|
687
|
+
new PutObjectCommand({
|
|
688
|
+
Bucket: this.bucket,
|
|
689
|
+
Key: key,
|
|
690
|
+
Body: buf,
|
|
691
|
+
ContentLength: size,
|
|
692
|
+
Metadata: metadataFor(timestamp),
|
|
693
|
+
...condition,
|
|
694
|
+
}),
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
return this.client.send(
|
|
322
698
|
new CompleteMultipartUploadCommand({
|
|
323
699
|
Bucket: this.bucket,
|
|
324
700
|
Key: key,
|
|
325
|
-
UploadId: uploadId
|
|
701
|
+
UploadId: uploadId!,
|
|
326
702
|
MultipartUpload: { Parts: uploadedParts },
|
|
703
|
+
...condition,
|
|
327
704
|
}),
|
|
328
705
|
);
|
|
329
|
-
}
|
|
706
|
+
};
|
|
330
707
|
|
|
331
|
-
|
|
708
|
+
const info: FileInfo = {
|
|
332
709
|
level: opts.level,
|
|
333
710
|
minTXID: opts.minTXID,
|
|
334
711
|
maxTXID: opts.maxTXID,
|
|
335
712
|
size,
|
|
336
713
|
createdAt: timestamp,
|
|
337
714
|
};
|
|
715
|
+
|
|
716
|
+
if (generation === null) {
|
|
717
|
+
await commit({});
|
|
718
|
+
completed = true;
|
|
719
|
+
return info;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// A multipart upload first looks for an existing object, so that the
|
|
723
|
+
// completion is not expected to fail (S3 may not keep the upload open).
|
|
724
|
+
const existing = await this.commitFenced(key, opts, generation, commit, uploadId !== null);
|
|
725
|
+
completed = existing === null;
|
|
726
|
+
|
|
727
|
+
// The upload is only acknowledged if this generation is still the latest
|
|
728
|
+
// after it landed: any generation registered later then sees the file.
|
|
729
|
+
await this.assertGeneration();
|
|
730
|
+
return existing ?? info;
|
|
338
731
|
} catch (err) {
|
|
339
|
-
if (
|
|
732
|
+
if (err instanceof ErrGenerationSuperseded) throw err;
|
|
733
|
+
throw new LTXError('write', key, opts.level, opts.minTXID, opts.maxTXID, err);
|
|
734
|
+
} finally {
|
|
735
|
+
if (uploadId !== null && !completed) {
|
|
340
736
|
await this.client
|
|
341
737
|
.send(
|
|
342
738
|
new AbortMultipartUploadCommand({
|
|
@@ -347,15 +743,83 @@ export class S3ReplicaClient implements ReplicaClient {
|
|
|
347
743
|
)
|
|
348
744
|
.catch(() => {});
|
|
349
745
|
}
|
|
350
|
-
throw new LTXError('write', key, opts.level, opts.minTXID, opts.maxTXID, err);
|
|
351
746
|
}
|
|
352
747
|
}
|
|
353
748
|
|
|
749
|
+
/**
|
|
750
|
+
* Creates a fenced LTX object with `If-None-Match: *`. If the key already
|
|
751
|
+
* exists:
|
|
752
|
+
* - written by a higher generation: this client is superseded;
|
|
753
|
+
* - written by this generation (a retried upload), or by a lower one but
|
|
754
|
+
* still part of the replica: it holds the same transactions, so it is
|
|
755
|
+
* kept and its metadata returned;
|
|
756
|
+
* - otherwise it is a superseded writer's file, replaced with `If-Match`.
|
|
757
|
+
* With `probe`, the key is checked with HeadObject before the first write.
|
|
758
|
+
* Returns null when this call created the object.
|
|
759
|
+
*/
|
|
760
|
+
private async commitFenced(
|
|
761
|
+
key: string,
|
|
762
|
+
opts: WriteLTXFileOptions,
|
|
763
|
+
generation: bigint,
|
|
764
|
+
commit: (condition: WriteCondition) => Promise<{ ETag?: string }>,
|
|
765
|
+
probe: boolean,
|
|
766
|
+
): Promise<FileInfo | null> {
|
|
767
|
+
let condition: WriteCondition = { IfNoneMatch: '*' };
|
|
768
|
+
for (let attempt = 0; attempt < MAX_CONDITIONAL_ATTEMPTS; attempt++) {
|
|
769
|
+
if (!probe) {
|
|
770
|
+
try {
|
|
771
|
+
const res = await commit(condition);
|
|
772
|
+
this.rememberGeneration(key, res.ETag, generation);
|
|
773
|
+
return null;
|
|
774
|
+
} catch (err) {
|
|
775
|
+
if (isConditionalConflict(err)) continue;
|
|
776
|
+
if (!isPreconditionFailed(err)) throw err;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
probe = false;
|
|
780
|
+
|
|
781
|
+
let head;
|
|
782
|
+
try {
|
|
783
|
+
head = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
784
|
+
} catch (err) {
|
|
785
|
+
if (isNotFound(err)) {
|
|
786
|
+
condition = { IfNoneMatch: '*' }; // deleted meanwhile
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
throw err;
|
|
790
|
+
}
|
|
791
|
+
const existing = metadataGeneration(head.Metadata);
|
|
792
|
+
if (existing > generation) throw this.superseded(existing);
|
|
793
|
+
const fences = (await this.readGenerationObject())?.state.fences ?? [];
|
|
794
|
+
if (existing === generation || isFenced(fences, existing, opts.maxTXID)) {
|
|
795
|
+
const timestamp = Number(head.Metadata?.['ltx-timestamp']);
|
|
796
|
+
return {
|
|
797
|
+
level: opts.level,
|
|
798
|
+
minTXID: opts.minTXID,
|
|
799
|
+
maxTXID: opts.maxTXID,
|
|
800
|
+
size: Number(head.ContentLength ?? 0),
|
|
801
|
+
createdAt: Number.isFinite(timestamp)
|
|
802
|
+
? timestamp
|
|
803
|
+
: (head.LastModified ?? new Date(0)).getTime(),
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
this.logger.debug('replacing ltx file of a superseded writer', { key, generation: existing });
|
|
807
|
+
condition = { IfMatch: String(head.ETag ?? '') };
|
|
808
|
+
}
|
|
809
|
+
throw new Error(`too many conflicting writes to ${key}`);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Deletes LTX files. A fenced client first checks that its generation is
|
|
814
|
+
* still the latest and fails with {@link ErrGenerationSuperseded} otherwise
|
|
815
|
+
* (a deletion sent just before a new generation registers still applies).
|
|
816
|
+
*/
|
|
354
817
|
async deleteLtxFiles(infos: FileInfo[]): Promise<void> {
|
|
355
818
|
const objects = infos.map((info) => ({
|
|
356
819
|
Key: this.fileKey(info.level, info.minTXID, info.maxTXID),
|
|
357
820
|
}));
|
|
358
821
|
for (let i = 0; i < objects.length; i += 1000) {
|
|
822
|
+
await this.assertGeneration();
|
|
359
823
|
await this.client.send(
|
|
360
824
|
new DeleteObjectsCommand({
|
|
361
825
|
Bucket: this.bucket,
|
|
@@ -365,16 +829,137 @@ export class S3ReplicaClient implements ReplicaClient {
|
|
|
365
829
|
}
|
|
366
830
|
}
|
|
367
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
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Deletes every object under the prefix except the generation object, so a
|
|
946
|
+
* superseded writer stays fenced. A fenced client checks its generation
|
|
947
|
+
* first, as {@link deleteLtxFiles} does.
|
|
948
|
+
*/
|
|
368
949
|
async deleteAll(): Promise<void> {
|
|
369
950
|
const prefix = this.prefix ? `${this.prefix}/` : '';
|
|
951
|
+
const generationKey = this.generationKey();
|
|
370
952
|
let token: string | undefined;
|
|
371
953
|
do {
|
|
372
954
|
const res = await this.client.send(
|
|
373
955
|
new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix, ContinuationToken: token }),
|
|
374
956
|
);
|
|
375
957
|
const contents = (res.Contents ?? []) as Array<{ Key?: string }>;
|
|
376
|
-
const objects = contents
|
|
958
|
+
const objects = contents
|
|
959
|
+
.filter((o) => o.Key && o.Key !== generationKey)
|
|
960
|
+
.map((o) => ({ Key: o.Key! }));
|
|
377
961
|
for (let i = 0; i < objects.length; i += 1000) {
|
|
962
|
+
await this.assertGeneration();
|
|
378
963
|
await this.client.send(
|
|
379
964
|
new DeleteObjectsCommand({
|
|
380
965
|
Bucket: this.bucket,
|