@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.
@@ -10,12 +10,117 @@
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
  import { Readable } from 'node:stream';
15
35
  import { S3Client, AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, DeleteObjectsCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, UploadPartCommand, } from '@aws-sdk/client-s3';
16
- import { HeaderSize, LTXError, 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
+ import { isConditionalConflict, isNotFound, isPreconditionFailed, readBody } from "./util.js";
17
38
  /** The strategy identifier reported by {@link S3ReplicaClient.type}. */
18
39
  export const ReplicaClientType = 's3';
40
+ /** Name of the generation object, relative to the replica prefix. */
41
+ export const GenerationObjectName = 'generation.json';
42
+ /** Object metadata key carrying the generation of a fenced LTX upload. */
43
+ export const GenerationMetadataKey = 'ltx-generation';
44
+ /**
45
+ * Returns true if a file of `generation` ending at `maxTXID` is part of the
46
+ * replica: no fence of a higher generation was recorded below `maxTXID`.
47
+ */
48
+ export function isFenced(fences, generation, maxTXID) {
49
+ for (const f of fences) {
50
+ if (f.generation > generation && maxTXID > f.txid)
51
+ return false;
52
+ }
53
+ return true;
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
+ }
80
+ /** Retries of a conditional write that keeps conflicting with concurrent ones. */
81
+ const MAX_CONDITIONAL_ATTEMPTS = 10;
82
+ /** HeadObject requests in flight while resolving the generation of listed files. */
83
+ const HEAD_CONCURRENCY = 16;
84
+ /** Cached generations of listed objects (by key and ETag). */
85
+ const GENERATION_CACHE_SIZE = 10_000;
86
+ /** Parses the `ltx-generation` metadata (0 when absent or invalid). */
87
+ function metadataGeneration(metadata) {
88
+ const v = metadata?.[GenerationMetadataKey];
89
+ if (v === undefined || !/^\d+$/.test(v))
90
+ return 0n;
91
+ return BigInt(v);
92
+ }
93
+ /** Serializes a generation state (bigints as decimal strings). */
94
+ function encodeGenerationState(state) {
95
+ return new TextEncoder().encode(JSON.stringify({
96
+ generation: state.generation.toString(),
97
+ fences: state.fences.map((f) => ({ generation: f.generation.toString(), txid: f.txid.toString() })),
98
+ }));
99
+ }
100
+ /** Parses a generation object body. */
101
+ function decodeGenerationState(data) {
102
+ const parsed = JSON.parse(new TextDecoder().decode(data));
103
+ return {
104
+ generation: BigInt(parsed.generation ?? '0'),
105
+ fences: (parsed.fences ?? []).map((f) => ({
106
+ generation: BigInt(f.generation ?? '0'),
107
+ txid: BigInt(f.txid ?? '0'),
108
+ })),
109
+ };
110
+ }
111
+ /** Runs `fn` over `items` with at most `limit` calls in flight, preserving order. */
112
+ async function mapLimit(items, limit, fn) {
113
+ const out = new Array(items.length);
114
+ let next = 0;
115
+ const worker = async () => {
116
+ while (next < items.length) {
117
+ const i = next++;
118
+ out[i] = await fn(items[i]);
119
+ }
120
+ };
121
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
122
+ return out;
123
+ }
19
124
  /** Normalized base prefix (no leading/trailing slash, `''` when empty). */
20
125
  function normalizePrefix(prefix) {
21
126
  return prefix.replace(/^\/+/, '').replace(/\/+$/, '');
@@ -41,25 +146,19 @@ export class S3ReplicaClient {
41
146
  prefix;
42
147
  client;
43
148
  logger = noopLogger;
149
+ generation;
150
+ /** The higher generation that superseded this client, once observed. */
151
+ supersededBy = null;
152
+ /** Generation of listed objects, keyed by `key` + ETag (objects are immutable). */
153
+ objectGenerations = new Map();
44
154
  constructor(opts) {
45
155
  if (!opts.bucket) {
46
156
  throw new Error('S3 replica client: bucket is required');
47
157
  }
48
158
  this.bucket = opts.bucket;
49
159
  this.prefix = normalizePrefix(opts.prefix ?? '');
50
- this.client =
51
- opts.client ??
52
- new S3Client({
53
- region: opts.region,
54
- endpoint: opts.endpoint,
55
- credentials: opts.accessKeyId
56
- ? {
57
- accessKeyId: opts.accessKeyId,
58
- secretAccessKey: opts.secretAccessKey ?? '',
59
- sessionToken: opts.sessionToken,
60
- }
61
- : undefined,
62
- });
160
+ this.generation = opts.generation ?? null;
161
+ this.client = createClient(opts);
63
162
  }
64
163
  type() {
65
164
  return ReplicaClientType;
@@ -88,8 +187,169 @@ export class S3ReplicaClient {
88
187
  fileKey(level, minTXID, maxTXID) {
89
188
  return `${this.levelPrefix(level)}${formatFilename(minTXID, maxTXID)}`;
90
189
  }
190
+ /** Returns the key of the generation object. */
191
+ generationKey() {
192
+ return `${this.prefix ? `${this.prefix}/` : ''}${GenerationObjectName}`;
193
+ }
194
+ /** Current fenced-write generation, or `null` when fencing is disabled. */
195
+ getGeneration() {
196
+ return this.generation;
197
+ }
198
+ /** Reads the generation object, or returns `null` if fencing was never used. */
199
+ async getGenerationState() {
200
+ return (await this.readGenerationObject())?.state ?? null;
201
+ }
202
+ async readGenerationObject() {
203
+ let res;
204
+ try {
205
+ res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: this.generationKey() }));
206
+ }
207
+ catch (err) {
208
+ if (isNotFound(err))
209
+ return null;
210
+ throw err;
211
+ }
212
+ const state = decodeGenerationState(await readBody(res.Body));
213
+ return { state, etag: String(res.ETag ?? '') };
214
+ }
215
+ /**
216
+ * Writes the generation object if it was not changed since it was read with
217
+ * `etag` (`null`: if it does not exist). Returns false on a lost race.
218
+ */
219
+ async writeGenerationObject(state, etag) {
220
+ try {
221
+ await this.client.send(new PutObjectCommand({
222
+ Bucket: this.bucket,
223
+ Key: this.generationKey(),
224
+ Body: encodeGenerationState(state),
225
+ ContentType: 'application/json',
226
+ ...(etag === null ? { IfNoneMatch: '*' } : { IfMatch: etag }),
227
+ }));
228
+ return true;
229
+ }
230
+ catch (err) {
231
+ if (isPreconditionFailed(err) || isConditionalConflict(err))
232
+ return false;
233
+ throw err;
234
+ }
235
+ }
236
+ /** Records that this client was superseded by `generation` and returns the error. */
237
+ superseded(generation) {
238
+ this.supersededBy = generation;
239
+ return new ErrGenerationSuperseded(generation);
240
+ }
241
+ /**
242
+ * Fails with {@link ErrGenerationSuperseded} if a generation higher than this
243
+ * client's is registered. A no-op when fencing is disabled.
244
+ */
245
+ async assertGeneration() {
246
+ if (this.generation === null)
247
+ return;
248
+ if (this.supersededBy !== null)
249
+ throw new ErrGenerationSuperseded(this.supersededBy);
250
+ const cur = await this.readGenerationObject();
251
+ if (cur !== null && cur.state.generation > this.generation) {
252
+ throw this.superseded(cur.state.generation);
253
+ }
254
+ }
255
+ /**
256
+ * Raises the replica's generation to `generation`, failing with
257
+ * {@link ErrGenerationSuperseded} if a higher one is already registered, and
258
+ * records a fence at the replica's latest TXID as read after the
259
+ * registration. From then on, files of lower generations above that TXID
260
+ * are ignored by every listing, and uploads and deletions of lower
261
+ * generations fail once they observe the new generation.
262
+ *
263
+ * Call it before restoring: the restore then sees every file that a former
264
+ * writer can still make part of the replica. Registering the current
265
+ * generation again is a no-op that keeps its fence.
266
+ */
267
+ async registerGeneration(generation) {
268
+ // 1. Raise the generation (compare-and-swap on the generation object).
269
+ for (let attempt = 0;; attempt++) {
270
+ if (attempt >= MAX_CONDITIONAL_ATTEMPTS) {
271
+ throw new Error('S3 replica client: too many conflicts registering the generation');
272
+ }
273
+ const cur = await this.readGenerationObject();
274
+ if (cur !== null && cur.state.generation > generation) {
275
+ throw new ErrGenerationSuperseded(cur.state.generation);
276
+ }
277
+ if (cur !== null && cur.state.generation === generation)
278
+ break;
279
+ const next = { generation, fences: cur?.state.fences ?? [] };
280
+ if (await this.writeGenerationObject(next, cur?.etag ?? null))
281
+ break;
282
+ }
283
+ this.generation = generation;
284
+ this.supersededBy = null;
285
+ // 2. Read the latest TXID of the replica. Files of lower generations that
286
+ // land from now on and end above it are not part of the replica.
287
+ let txid = 0n;
288
+ for (let level = 0; level <= SnapshotLevel; level++) {
289
+ for await (const info of this.ltxFiles({ level, seek: 0n, useMetadata: false })) {
290
+ if (info.maxTXID > txid)
291
+ txid = info.maxTXID;
292
+ }
293
+ }
294
+ // 3. Record the fence, unless a higher generation registered meanwhile.
295
+ for (let attempt = 0;; attempt++) {
296
+ if (attempt >= MAX_CONDITIONAL_ATTEMPTS) {
297
+ throw new Error('S3 replica client: too many conflicts recording the generation fence');
298
+ }
299
+ const cur = await this.readGenerationObject();
300
+ const stored = cur?.state.generation ?? generation;
301
+ if (stored > generation)
302
+ throw this.superseded(stored);
303
+ const fences = cur?.state.fences ?? [];
304
+ if (fences.some((f) => f.generation === generation))
305
+ break;
306
+ const next = { generation, fences: [...fences, { generation, txid }] };
307
+ if (await this.writeGenerationObject(next, cur?.etag ?? null))
308
+ break;
309
+ }
310
+ this.logger.debug('registered generation', { generation, txid });
311
+ }
312
+ /** Returns the generation of an object (from `ltx-generation`), or null if it is gone. */
313
+ async objectGeneration(key, etag) {
314
+ const cacheKey = etag ? `${key}\n${etag}` : null;
315
+ if (cacheKey !== null) {
316
+ const cached = this.objectGenerations.get(cacheKey);
317
+ if (cached !== undefined)
318
+ return cached;
319
+ }
320
+ let head;
321
+ try {
322
+ head = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
323
+ }
324
+ catch (err) {
325
+ if (isNotFound(err))
326
+ return null;
327
+ throw err;
328
+ }
329
+ const generation = metadataGeneration(head.Metadata);
330
+ this.rememberGeneration(key, head.ETag ?? etag, generation);
331
+ return generation;
332
+ }
333
+ rememberGeneration(key, etag, generation) {
334
+ if (!etag)
335
+ return;
336
+ if (this.objectGenerations.size >= GENERATION_CACHE_SIZE)
337
+ this.objectGenerations.clear();
338
+ this.objectGenerations.set(`${key}\n${etag}`, generation);
339
+ }
340
+ /**
341
+ * Lists the LTX files of a level. When the replica has fences, files ending
342
+ * above the lowest fence need their generation (a HeadObject request, cached
343
+ * per key and ETag), and those written by a superseded writer are skipped.
344
+ */
91
345
  async *ltxFiles(opts) {
92
346
  const prefix = this.levelPrefix(opts.level);
347
+ const fences = (await this.readGenerationObject())?.state.fences ?? [];
348
+ let lowestFence = null;
349
+ for (const f of fences) {
350
+ if (lowestFence === null || f.txid < lowestFence)
351
+ lowestFence = f.txid;
352
+ }
93
353
  let token;
94
354
  do {
95
355
  const res = await this.client.send(new ListObjectsV2Command({
@@ -97,6 +357,7 @@ export class S3ReplicaClient {
97
357
  Prefix: prefix,
98
358
  ContinuationToken: token,
99
359
  }));
360
+ const page = [];
100
361
  for (const obj of res.Contents ?? []) {
101
362
  if (!obj.Key)
102
363
  continue;
@@ -111,13 +372,31 @@ export class S3ReplicaClient {
111
372
  }
112
373
  if (minTXID < opts.seek)
113
374
  continue;
114
- yield {
115
- level: opts.level,
116
- minTXID,
117
- maxTXID,
118
- size: obj.Size ?? 0,
119
- createdAt: (obj.LastModified ?? new Date(0)).getTime(),
120
- };
375
+ page.push({
376
+ key: obj.Key,
377
+ etag: obj.ETag,
378
+ info: {
379
+ level: opts.level,
380
+ minTXID,
381
+ maxTXID,
382
+ size: obj.Size ?? 0,
383
+ createdAt: (obj.LastModified ?? new Date(0)).getTime(),
384
+ },
385
+ });
386
+ }
387
+ let visible = page.map(() => true);
388
+ if (lowestFence !== null) {
389
+ const fence = lowestFence;
390
+ visible = await mapLimit(page, HEAD_CONCURRENCY, async (p) => {
391
+ if (p.info.maxTXID <= fence)
392
+ return true;
393
+ const generation = await this.objectGeneration(p.key, p.etag);
394
+ return generation !== null && isFenced(fences, generation, p.info.maxTXID);
395
+ });
396
+ }
397
+ for (let i = 0; i < page.length; i++) {
398
+ if (visible[i])
399
+ yield page[i].info;
121
400
  }
122
401
  token = res.IsTruncated ? (res.NextContinuationToken ?? undefined) : undefined;
123
402
  } while (token);
@@ -162,6 +441,10 @@ export class S3ReplicaClient {
162
441
  }
163
442
  async writeLtxFile(opts, body) {
164
443
  const key = this.fileKey(opts.level, opts.minTXID, opts.maxTXID);
444
+ const generation = this.generation;
445
+ if (generation !== null && this.supersededBy !== null) {
446
+ throw new ErrGenerationSuperseded(this.supersededBy);
447
+ }
165
448
  // Capture the LTX header (timestamp) from the leading bytes, and stream the
166
449
  // rest. Small objects use a single PutObject; large objects (> 5 MiB) are
167
450
  // uploaded with the multipart API so the whole file is never buffered.
@@ -172,6 +455,7 @@ export class S3ReplicaClient {
172
455
  let accLen = 0;
173
456
  let size = 0;
174
457
  let uploadId = null;
458
+ let completed = false;
175
459
  let partNumber = 0;
176
460
  const uploadedParts = [];
177
461
  const uploadPart = async (buf) => {
@@ -185,6 +469,12 @@ export class S3ReplicaClient {
185
469
  }));
186
470
  uploadedParts.push({ PartNumber: partNumber, ETag: res.ETag ?? `etag-${partNumber}` });
187
471
  };
472
+ const metadataFor = (timestamp) => {
473
+ const metadata = { 'ltx-timestamp': String(timestamp) };
474
+ if (generation !== null)
475
+ metadata[GenerationMetadataKey] = generation.toString();
476
+ return metadata;
477
+ };
188
478
  try {
189
479
  for await (const chunk of body) {
190
480
  const bytes = typeof chunk === 'string' ? new TextEncoder().encode(chunk) : Uint8Array.from(chunk);
@@ -206,7 +496,7 @@ export class S3ReplicaClient {
206
496
  const res = await this.client.send(new CreateMultipartUploadCommand({
207
497
  Bucket: this.bucket,
208
498
  Key: key,
209
- Metadata: { 'ltx-timestamp': String(Number(unmarshalHeader(headerBytes).timestamp)) },
499
+ Metadata: metadataFor(Number(unmarshalHeader(headerBytes).timestamp)),
210
500
  }));
211
501
  uploadId = res.UploadId;
212
502
  await uploadPart(concatBytes(acc));
@@ -223,39 +513,62 @@ export class S3ReplicaClient {
223
513
  throw new Error('write ltx file: input too short to contain a header');
224
514
  }
225
515
  const timestamp = Number(unmarshalHeader(headerBytes).timestamp);
226
- const metadata = { 'ltx-timestamp': String(timestamp) };
227
- if (uploadId === null) {
228
- // Single PutObject for small objects.
229
- const buf = concatBytes(acc);
230
- await this.client.send(new PutObjectCommand({
231
- Bucket: this.bucket,
232
- Key: key,
233
- Body: buf,
234
- ContentLength: size,
235
- Metadata: metadata,
236
- }));
516
+ // Multipart: upload any remaining bytes as the final part.
517
+ if (uploadId !== null && accLen > 0) {
518
+ await uploadPart(concatBytes(acc));
519
+ acc = [];
520
+ accLen = 0;
237
521
  }
238
- else {
239
- // Multipart: upload any remaining bytes as the final part, then complete.
240
- if (accLen > 0)
241
- await uploadPart(concatBytes(acc));
242
- await this.client.send(new CompleteMultipartUploadCommand({
522
+ const buf = uploadId === null ? concatBytes(acc) : null;
523
+ // Creates the object: a single PutObject for small objects, otherwise
524
+ // completes the multipart upload.
525
+ const commit = (condition) => {
526
+ if (buf !== null) {
527
+ return this.client.send(new PutObjectCommand({
528
+ Bucket: this.bucket,
529
+ Key: key,
530
+ Body: buf,
531
+ ContentLength: size,
532
+ Metadata: metadataFor(timestamp),
533
+ ...condition,
534
+ }));
535
+ }
536
+ return this.client.send(new CompleteMultipartUploadCommand({
243
537
  Bucket: this.bucket,
244
538
  Key: key,
245
539
  UploadId: uploadId,
246
540
  MultipartUpload: { Parts: uploadedParts },
541
+ ...condition,
247
542
  }));
248
- }
249
- return {
543
+ };
544
+ const info = {
250
545
  level: opts.level,
251
546
  minTXID: opts.minTXID,
252
547
  maxTXID: opts.maxTXID,
253
548
  size,
254
549
  createdAt: timestamp,
255
550
  };
551
+ if (generation === null) {
552
+ await commit({});
553
+ completed = true;
554
+ return info;
555
+ }
556
+ // A multipart upload first looks for an existing object, so that the
557
+ // completion is not expected to fail (S3 may not keep the upload open).
558
+ const existing = await this.commitFenced(key, opts, generation, commit, uploadId !== null);
559
+ completed = existing === null;
560
+ // The upload is only acknowledged if this generation is still the latest
561
+ // after it landed: any generation registered later then sees the file.
562
+ await this.assertGeneration();
563
+ return existing ?? info;
256
564
  }
257
565
  catch (err) {
258
- if (uploadId !== null) {
566
+ if (err instanceof ErrGenerationSuperseded)
567
+ throw err;
568
+ throw new LTXError('write', key, opts.level, opts.minTXID, opts.maxTXID, err);
569
+ }
570
+ finally {
571
+ if (uploadId !== null && !completed) {
259
572
  await this.client
260
573
  .send(new AbortMultipartUploadCommand({
261
574
  Bucket: this.bucket,
@@ -264,28 +577,209 @@ export class S3ReplicaClient {
264
577
  }))
265
578
  .catch(() => { });
266
579
  }
267
- throw new LTXError('write', key, opts.level, opts.minTXID, opts.maxTXID, err);
268
580
  }
269
581
  }
582
+ /**
583
+ * Creates a fenced LTX object with `If-None-Match: *`. If the key already
584
+ * exists:
585
+ * - written by a higher generation: this client is superseded;
586
+ * - written by this generation (a retried upload), or by a lower one but
587
+ * still part of the replica: it holds the same transactions, so it is
588
+ * kept and its metadata returned;
589
+ * - otherwise it is a superseded writer's file, replaced with `If-Match`.
590
+ * With `probe`, the key is checked with HeadObject before the first write.
591
+ * Returns null when this call created the object.
592
+ */
593
+ async commitFenced(key, opts, generation, commit, probe) {
594
+ let condition = { IfNoneMatch: '*' };
595
+ for (let attempt = 0; attempt < MAX_CONDITIONAL_ATTEMPTS; attempt++) {
596
+ if (!probe) {
597
+ try {
598
+ const res = await commit(condition);
599
+ this.rememberGeneration(key, res.ETag, generation);
600
+ return null;
601
+ }
602
+ catch (err) {
603
+ if (isConditionalConflict(err))
604
+ continue;
605
+ if (!isPreconditionFailed(err))
606
+ throw err;
607
+ }
608
+ }
609
+ probe = false;
610
+ let head;
611
+ try {
612
+ head = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
613
+ }
614
+ catch (err) {
615
+ if (isNotFound(err)) {
616
+ condition = { IfNoneMatch: '*' }; // deleted meanwhile
617
+ continue;
618
+ }
619
+ throw err;
620
+ }
621
+ const existing = metadataGeneration(head.Metadata);
622
+ if (existing > generation)
623
+ throw this.superseded(existing);
624
+ const fences = (await this.readGenerationObject())?.state.fences ?? [];
625
+ if (existing === generation || isFenced(fences, existing, opts.maxTXID)) {
626
+ const timestamp = Number(head.Metadata?.['ltx-timestamp']);
627
+ return {
628
+ level: opts.level,
629
+ minTXID: opts.minTXID,
630
+ maxTXID: opts.maxTXID,
631
+ size: Number(head.ContentLength ?? 0),
632
+ createdAt: Number.isFinite(timestamp)
633
+ ? timestamp
634
+ : (head.LastModified ?? new Date(0)).getTime(),
635
+ };
636
+ }
637
+ this.logger.debug('replacing ltx file of a superseded writer', { key, generation: existing });
638
+ condition = { IfMatch: String(head.ETag ?? '') };
639
+ }
640
+ throw new Error(`too many conflicting writes to ${key}`);
641
+ }
642
+ /**
643
+ * Deletes LTX files. A fenced client first checks that its generation is
644
+ * still the latest and fails with {@link ErrGenerationSuperseded} otherwise
645
+ * (a deletion sent just before a new generation registers still applies).
646
+ */
270
647
  async deleteLtxFiles(infos) {
271
648
  const objects = infos.map((info) => ({
272
649
  Key: this.fileKey(info.level, info.minTXID, info.maxTXID),
273
650
  }));
274
651
  for (let i = 0; i < objects.length; i += 1000) {
652
+ await this.assertGeneration();
275
653
  await this.client.send(new DeleteObjectsCommand({
276
654
  Bucket: this.bucket,
277
655
  Delete: { Objects: objects.slice(i, i + 1000), Quiet: true },
278
656
  }));
279
657
  }
280
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
+ }
766
+ /**
767
+ * Deletes every object under the prefix except the generation object, so a
768
+ * superseded writer stays fenced. A fenced client checks its generation
769
+ * first, as {@link deleteLtxFiles} does.
770
+ */
281
771
  async deleteAll() {
282
772
  const prefix = this.prefix ? `${this.prefix}/` : '';
773
+ const generationKey = this.generationKey();
283
774
  let token;
284
775
  do {
285
776
  const res = await this.client.send(new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix, ContinuationToken: token }));
286
777
  const contents = (res.Contents ?? []);
287
- const objects = contents.filter((o) => o.Key).map((o) => ({ Key: o.Key }));
778
+ const objects = contents
779
+ .filter((o) => o.Key && o.Key !== generationKey)
780
+ .map((o) => ({ Key: o.Key }));
288
781
  for (let i = 0; i < objects.length; i += 1000) {
782
+ await this.assertGeneration();
289
783
  await this.client.send(new DeleteObjectsCommand({
290
784
  Bucket: this.bucket,
291
785
  Delete: { Objects: objects.slice(i, i + 1000), Quiet: true },