@platformatic/sqlite-js-stream-s3 0.2.0 → 0.3.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/src/leaser.ts CHANGED
@@ -22,60 +22,7 @@ import {
22
22
  } from '@platformatic/sqlite-js-stream';
23
23
 
24
24
  import type { S3ClientLike } from './replica-client.ts';
25
-
26
- /** Reads all bytes from an S3 body (stream or Buffer). */
27
- async function readBody(body: any): Promise<Uint8Array> {
28
- // AWS SDK v3 returns a mix; normalize to a Uint8Array.
29
- if (body instanceof Uint8Array) return body;
30
- if (typeof body?.on === 'function') {
31
- // Node stream.
32
- const chunks: Uint8Array[] = [];
33
- for await (const chunk of body as AsyncIterable<Uint8Array | string>) {
34
- chunks.push(typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk);
35
- }
36
- let total = 0;
37
- for (const c of chunks) total += c.length;
38
- const out = new Uint8Array(total);
39
- let o = 0;
40
- for (const c of chunks) {
41
- out.set(c, o);
42
- o += c.length;
43
- }
44
- return out;
45
- }
46
- // Web ReadableStream or Buffer.
47
- if (typeof body?.getReader === 'function') {
48
- const reader = (body as ReadableStream<Uint8Array>).getReader();
49
- const chunks: Uint8Array[] = [];
50
- for (;;) {
51
- const { done, value } = await reader.read();
52
- if (done) break;
53
- chunks.push(value);
54
- }
55
- let total = 0;
56
- for (const c of chunks) total += c.length;
57
- const out = new Uint8Array(total);
58
- let o = 0;
59
- for (const c of chunks) {
60
- out.set(c, o);
61
- o += c.length;
62
- }
63
- return out;
64
- }
65
- return new Uint8Array(body as Uint8Array);
66
- }
67
-
68
- function isPreconditionFailed(err: unknown): boolean {
69
- const code = (err as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
70
- const name = (err as { name?: string })?.name ?? '';
71
- return code === 412 || name === 'PreconditionFailed';
72
- }
73
-
74
- function isNotFound(err: unknown): boolean {
75
- const code = (err as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
76
- const name = (err as { name?: string })?.name ?? '';
77
- return code === 404 || name === 'NoSuchKey' || name === 'NotFound';
78
- }
25
+ import { isNotFound, isPreconditionFailed, readBody } from './util.ts';
79
26
 
80
27
  /** S3Leaser acquires/renews/releases a lease object in an S3 bucket. */
81
28
  export class S3Leaser implements Leaser {
@@ -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,10 @@ import {
29
49
  } from '@aws-sdk/client-s3';
30
50
 
31
51
  import {
52
+ ErrGenerationSuperseded,
32
53
  HeaderSize,
33
54
  LTXError,
55
+ SnapshotLevel,
34
56
  formatFilename,
35
57
  noopLogger,
36
58
  parseFilename,
@@ -43,6 +65,8 @@ import {
43
65
  type WriteLTXFileOptions,
44
66
  } from '@platformatic/sqlite-js-stream';
45
67
 
68
+ import { isConditionalConflict, isNotFound, isPreconditionFailed, readBody } from './util.ts';
69
+
46
70
  /** The strategy identifier reported by {@link S3ReplicaClient.type}. */
47
71
  export const ReplicaClientType = 's3';
48
72
 
@@ -69,8 +93,107 @@ export interface S3ReplicaClientOptions {
69
93
  sessionToken?: string;
70
94
  /** Inject a preconfigured client. Mostly for tests. */
71
95
  client?: S3ClientLike;
96
+ /**
97
+ * Fenced-write generation, or `null` (the default) to disable fencing. Set
98
+ * by {@link S3ReplicaClient.registerGeneration}; when set, uploads and
99
+ * deletions fail with {@link ErrGenerationSuperseded} once a higher
100
+ * generation is registered.
101
+ */
102
+ generation?: bigint | null;
103
+ }
104
+
105
+ /** Name of the generation object, relative to the replica prefix. */
106
+ export const GenerationObjectName = 'generation.json';
107
+
108
+ /** Object metadata key carrying the generation of a fenced LTX upload. */
109
+ export const GenerationMetadataKey = 'ltx-generation';
110
+
111
+ /**
112
+ * A fence: `generation` was registered when the latest TXID of the replica was
113
+ * `txid`. Files of lower generations with a `maxTXID` above `txid` are ignored.
114
+ */
115
+ export interface GenerationFence {
116
+ generation: bigint;
117
+ txid: bigint;
118
+ }
119
+
120
+ /** Contents of the generation object. */
121
+ export interface GenerationState {
122
+ /** The highest registered generation. */
123
+ generation: bigint;
124
+ /** Fences, in registration order (one per generation that completed registration). */
125
+ fences: GenerationFence[];
126
+ }
127
+
128
+ /**
129
+ * Returns true if a file of `generation` ending at `maxTXID` is part of the
130
+ * replica: no fence of a higher generation was recorded below `maxTXID`.
131
+ */
132
+ export function isFenced(fences: readonly GenerationFence[], generation: bigint, maxTXID: bigint): boolean {
133
+ for (const f of fences) {
134
+ if (f.generation > generation && maxTXID > f.txid) return false;
135
+ }
136
+ return true;
72
137
  }
73
138
 
139
+ /** Retries of a conditional write that keeps conflicting with concurrent ones. */
140
+ const MAX_CONDITIONAL_ATTEMPTS = 10;
141
+
142
+ /** HeadObject requests in flight while resolving the generation of listed files. */
143
+ const HEAD_CONCURRENCY = 16;
144
+
145
+ /** Cached generations of listed objects (by key and ETag). */
146
+ const GENERATION_CACHE_SIZE = 10_000;
147
+
148
+ /** Parses the `ltx-generation` metadata (0 when absent or invalid). */
149
+ function metadataGeneration(metadata: Record<string, string> | undefined): bigint {
150
+ const v = metadata?.[GenerationMetadataKey];
151
+ if (v === undefined || !/^\d+$/.test(v)) return 0n;
152
+ return BigInt(v);
153
+ }
154
+
155
+ /** Serializes a generation state (bigints as decimal strings). */
156
+ function encodeGenerationState(state: GenerationState): Uint8Array {
157
+ return new TextEncoder().encode(
158
+ JSON.stringify({
159
+ generation: state.generation.toString(),
160
+ fences: state.fences.map((f) => ({ generation: f.generation.toString(), txid: f.txid.toString() })),
161
+ }),
162
+ );
163
+ }
164
+
165
+ /** Parses a generation object body. */
166
+ function decodeGenerationState(data: Uint8Array): GenerationState {
167
+ const parsed = JSON.parse(new TextDecoder().decode(data)) as {
168
+ generation?: string;
169
+ fences?: Array<{ generation?: string; txid?: string }>;
170
+ };
171
+ return {
172
+ generation: BigInt(parsed.generation ?? '0'),
173
+ fences: (parsed.fences ?? []).map((f) => ({
174
+ generation: BigInt(f.generation ?? '0'),
175
+ txid: BigInt(f.txid ?? '0'),
176
+ })),
177
+ };
178
+ }
179
+
180
+ /** Runs `fn` over `items` with at most `limit` calls in flight, preserving order. */
181
+ async function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
182
+ const out = new Array<R>(items.length);
183
+ let next = 0;
184
+ const worker = async (): Promise<void> => {
185
+ while (next < items.length) {
186
+ const i = next++;
187
+ out[i] = await fn(items[i]);
188
+ }
189
+ };
190
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
191
+ return out;
192
+ }
193
+
194
+ /** Conditional headers of a write. */
195
+ type WriteCondition = { IfNoneMatch: '*' } | { IfMatch: string } | Record<string, never>;
196
+
74
197
  /** Normalized base prefix (no leading/trailing slash, `''` when empty). */
75
198
  function normalizePrefix(prefix: string): string {
76
199
  return prefix.replace(/^\/+/, '').replace(/\/+$/, '');
@@ -98,6 +221,11 @@ export class S3ReplicaClient implements ReplicaClient {
98
221
  readonly prefix: string;
99
222
  private client: S3ClientLike;
100
223
  private logger: Logger = noopLogger;
224
+ private generation: bigint | null;
225
+ /** The higher generation that superseded this client, once observed. */
226
+ private supersededBy: bigint | null = null;
227
+ /** Generation of listed objects, keyed by `key` + ETag (objects are immutable). */
228
+ private readonly objectGenerations = new Map<string, bigint>();
101
229
 
102
230
  constructor(opts: S3ReplicaClientOptions) {
103
231
  if (!opts.bucket) {
@@ -105,6 +233,7 @@ export class S3ReplicaClient implements ReplicaClient {
105
233
  }
106
234
  this.bucket = opts.bucket;
107
235
  this.prefix = normalizePrefix(opts.prefix ?? '');
236
+ this.generation = opts.generation ?? null;
108
237
  this.client =
109
238
  opts.client ??
110
239
  new S3Client({
@@ -152,8 +281,166 @@ export class S3ReplicaClient implements ReplicaClient {
152
281
  return `${this.levelPrefix(level)}${formatFilename(minTXID, maxTXID)}`;
153
282
  }
154
283
 
284
+ /** Returns the key of the generation object. */
285
+ private generationKey(): string {
286
+ return `${this.prefix ? `${this.prefix}/` : ''}${GenerationObjectName}`;
287
+ }
288
+
289
+ /** Current fenced-write generation, or `null` when fencing is disabled. */
290
+ getGeneration(): bigint | null {
291
+ return this.generation;
292
+ }
293
+
294
+ /** Reads the generation object, or returns `null` if fencing was never used. */
295
+ async getGenerationState(): Promise<GenerationState | null> {
296
+ return (await this.readGenerationObject())?.state ?? null;
297
+ }
298
+
299
+ private async readGenerationObject(): Promise<{ state: GenerationState; etag: string } | null> {
300
+ let res;
301
+ try {
302
+ res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: this.generationKey() }));
303
+ } catch (err) {
304
+ if (isNotFound(err)) return null;
305
+ throw err;
306
+ }
307
+ const state = decodeGenerationState(await readBody(res.Body));
308
+ return { state, etag: String(res.ETag ?? '') };
309
+ }
310
+
311
+ /**
312
+ * Writes the generation object if it was not changed since it was read with
313
+ * `etag` (`null`: if it does not exist). Returns false on a lost race.
314
+ */
315
+ private async writeGenerationObject(state: GenerationState, etag: string | null): Promise<boolean> {
316
+ try {
317
+ await this.client.send(
318
+ new PutObjectCommand({
319
+ Bucket: this.bucket,
320
+ Key: this.generationKey(),
321
+ Body: encodeGenerationState(state),
322
+ ContentType: 'application/json',
323
+ ...(etag === null ? { IfNoneMatch: '*' } : { IfMatch: etag }),
324
+ }),
325
+ );
326
+ return true;
327
+ } catch (err) {
328
+ if (isPreconditionFailed(err) || isConditionalConflict(err)) return false;
329
+ throw err;
330
+ }
331
+ }
332
+
333
+ /** Records that this client was superseded by `generation` and returns the error. */
334
+ private superseded(generation: bigint): ErrGenerationSuperseded {
335
+ this.supersededBy = generation;
336
+ return new ErrGenerationSuperseded(generation);
337
+ }
338
+
339
+ /**
340
+ * Fails with {@link ErrGenerationSuperseded} if a generation higher than this
341
+ * client's is registered. A no-op when fencing is disabled.
342
+ */
343
+ private async assertGeneration(): Promise<void> {
344
+ if (this.generation === null) return;
345
+ if (this.supersededBy !== null) throw new ErrGenerationSuperseded(this.supersededBy);
346
+ const cur = await this.readGenerationObject();
347
+ if (cur !== null && cur.state.generation > this.generation) {
348
+ throw this.superseded(cur.state.generation);
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Raises the replica's generation to `generation`, failing with
354
+ * {@link ErrGenerationSuperseded} if a higher one is already registered, and
355
+ * records a fence at the replica's latest TXID as read after the
356
+ * registration. From then on, files of lower generations above that TXID
357
+ * are ignored by every listing, and uploads and deletions of lower
358
+ * generations fail once they observe the new generation.
359
+ *
360
+ * Call it before restoring: the restore then sees every file that a former
361
+ * writer can still make part of the replica. Registering the current
362
+ * generation again is a no-op that keeps its fence.
363
+ */
364
+ async registerGeneration(generation: bigint): Promise<void> {
365
+ // 1. Raise the generation (compare-and-swap on the generation object).
366
+ for (let attempt = 0; ; attempt++) {
367
+ if (attempt >= MAX_CONDITIONAL_ATTEMPTS) {
368
+ throw new Error('S3 replica client: too many conflicts registering the generation');
369
+ }
370
+ const cur = await this.readGenerationObject();
371
+ if (cur !== null && cur.state.generation > generation) {
372
+ throw new ErrGenerationSuperseded(cur.state.generation);
373
+ }
374
+ if (cur !== null && cur.state.generation === generation) break;
375
+ const next = { generation, fences: cur?.state.fences ?? [] };
376
+ if (await this.writeGenerationObject(next, cur?.etag ?? null)) break;
377
+ }
378
+ this.generation = generation;
379
+ this.supersededBy = null;
380
+
381
+ // 2. Read the latest TXID of the replica. Files of lower generations that
382
+ // land from now on and end above it are not part of the replica.
383
+ let txid = 0n;
384
+ for (let level = 0; level <= SnapshotLevel; level++) {
385
+ for await (const info of this.ltxFiles({ level, seek: 0n, useMetadata: false })) {
386
+ if (info.maxTXID > txid) txid = info.maxTXID;
387
+ }
388
+ }
389
+
390
+ // 3. Record the fence, unless a higher generation registered meanwhile.
391
+ for (let attempt = 0; ; attempt++) {
392
+ if (attempt >= MAX_CONDITIONAL_ATTEMPTS) {
393
+ throw new Error('S3 replica client: too many conflicts recording the generation fence');
394
+ }
395
+ const cur = await this.readGenerationObject();
396
+ const stored = cur?.state.generation ?? generation;
397
+ if (stored > generation) throw this.superseded(stored);
398
+ const fences = cur?.state.fences ?? [];
399
+ if (fences.some((f) => f.generation === generation)) break;
400
+ const next = { generation, fences: [...fences, { generation, txid }] };
401
+ if (await this.writeGenerationObject(next, cur?.etag ?? null)) break;
402
+ }
403
+ this.logger.debug('registered generation', { generation, txid });
404
+ }
405
+
406
+ /** Returns the generation of an object (from `ltx-generation`), or null if it is gone. */
407
+ private async objectGeneration(key: string, etag: string | undefined): Promise<bigint | null> {
408
+ const cacheKey = etag ? `${key}\n${etag}` : null;
409
+ if (cacheKey !== null) {
410
+ const cached = this.objectGenerations.get(cacheKey);
411
+ if (cached !== undefined) return cached;
412
+ }
413
+ let head;
414
+ try {
415
+ head = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
416
+ } catch (err) {
417
+ if (isNotFound(err)) return null;
418
+ throw err;
419
+ }
420
+ const generation = metadataGeneration(head.Metadata);
421
+ this.rememberGeneration(key, head.ETag ?? etag, generation);
422
+ return generation;
423
+ }
424
+
425
+ private rememberGeneration(key: string, etag: string | undefined, generation: bigint): void {
426
+ if (!etag) return;
427
+ if (this.objectGenerations.size >= GENERATION_CACHE_SIZE) this.objectGenerations.clear();
428
+ this.objectGenerations.set(`${key}\n${etag}`, generation);
429
+ }
430
+
431
+ /**
432
+ * Lists the LTX files of a level. When the replica has fences, files ending
433
+ * above the lowest fence need their generation (a HeadObject request, cached
434
+ * per key and ETag), and those written by a superseded writer are skipped.
435
+ */
155
436
  async *ltxFiles(opts: LTXFilesOptions): AsyncIterable<FileInfo> {
156
437
  const prefix = this.levelPrefix(opts.level);
438
+ const fences = (await this.readGenerationObject())?.state.fences ?? [];
439
+ let lowestFence: bigint | null = null;
440
+ for (const f of fences) {
441
+ if (lowestFence === null || f.txid < lowestFence) lowestFence = f.txid;
442
+ }
443
+
157
444
  let token: string | undefined;
158
445
  do {
159
446
  const res = await this.client.send(
@@ -163,6 +450,7 @@ export class S3ReplicaClient implements ReplicaClient {
163
450
  ContinuationToken: token,
164
451
  }),
165
452
  );
453
+ const page: Array<{ info: FileInfo; key: string; etag: string | undefined }> = [];
166
454
  for (const obj of res.Contents ?? []) {
167
455
  if (!obj.Key) continue;
168
456
  const name = obj.Key.slice(prefix.length);
@@ -174,13 +462,30 @@ export class S3ReplicaClient implements ReplicaClient {
174
462
  continue; // skip non-conforming keys
175
463
  }
176
464
  if (minTXID < opts.seek) continue;
177
- yield {
178
- level: opts.level,
179
- minTXID,
180
- maxTXID,
181
- size: obj.Size ?? 0,
182
- createdAt: (obj.LastModified ?? new Date(0)).getTime(),
183
- };
465
+ page.push({
466
+ key: obj.Key,
467
+ etag: obj.ETag,
468
+ info: {
469
+ level: opts.level,
470
+ minTXID,
471
+ maxTXID,
472
+ size: obj.Size ?? 0,
473
+ createdAt: (obj.LastModified ?? new Date(0)).getTime(),
474
+ },
475
+ });
476
+ }
477
+
478
+ let visible: boolean[] = page.map(() => true);
479
+ if (lowestFence !== null) {
480
+ const fence = lowestFence;
481
+ visible = await mapLimit(page, HEAD_CONCURRENCY, async (p) => {
482
+ if (p.info.maxTXID <= fence) return true;
483
+ const generation = await this.objectGeneration(p.key, p.etag);
484
+ return generation !== null && isFenced(fences, generation, p.info.maxTXID);
485
+ });
486
+ }
487
+ for (let i = 0; i < page.length; i++) {
488
+ if (visible[i]) yield page[i].info;
184
489
  }
185
490
  token = res.IsTruncated ? (res.NextContinuationToken ?? undefined) : undefined;
186
491
  } while (token);
@@ -228,6 +533,10 @@ export class S3ReplicaClient implements ReplicaClient {
228
533
 
229
534
  async writeLtxFile(opts: WriteLTXFileOptions, body: AsyncIterable<Uint8Array | string>): Promise<FileInfo> {
230
535
  const key = this.fileKey(opts.level, opts.minTXID, opts.maxTXID);
536
+ const generation = this.generation;
537
+ if (generation !== null && this.supersededBy !== null) {
538
+ throw new ErrGenerationSuperseded(this.supersededBy);
539
+ }
231
540
 
232
541
  // Capture the LTX header (timestamp) from the leading bytes, and stream the
233
542
  // rest. Small objects use a single PutObject; large objects (> 5 MiB) are
@@ -241,6 +550,7 @@ export class S3ReplicaClient implements ReplicaClient {
241
550
  let size = 0;
242
551
 
243
552
  let uploadId: string | null = null;
553
+ let completed = false;
244
554
  let partNumber = 0;
245
555
  const uploadedParts: Array<{ PartNumber: number; ETag: string }> = [];
246
556
 
@@ -258,6 +568,12 @@ export class S3ReplicaClient implements ReplicaClient {
258
568
  uploadedParts.push({ PartNumber: partNumber, ETag: res.ETag ?? `etag-${partNumber}` });
259
569
  };
260
570
 
571
+ const metadataFor = (timestamp: number): Record<string, string> => {
572
+ const metadata: Record<string, string> = { 'ltx-timestamp': String(timestamp) };
573
+ if (generation !== null) metadata[GenerationMetadataKey] = generation.toString();
574
+ return metadata;
575
+ };
576
+
261
577
  try {
262
578
  for await (const chunk of body) {
263
579
  const bytes =
@@ -283,7 +599,7 @@ export class S3ReplicaClient implements ReplicaClient {
283
599
  new CreateMultipartUploadCommand({
284
600
  Bucket: this.bucket,
285
601
  Key: key,
286
- Metadata: { 'ltx-timestamp': String(Number(unmarshalHeader(headerBytes!).timestamp)) },
602
+ Metadata: metadataFor(Number(unmarshalHeader(headerBytes!).timestamp)),
287
603
  }),
288
604
  );
289
605
  uploadId = res.UploadId;
@@ -301,42 +617,69 @@ export class S3ReplicaClient implements ReplicaClient {
301
617
  throw new Error('write ltx file: input too short to contain a header');
302
618
  }
303
619
  const timestamp = Number(unmarshalHeader(headerBytes).timestamp);
304
- const metadata = { 'ltx-timestamp': String(timestamp) };
305
620
 
306
- if (uploadId === null) {
307
- // Single PutObject for small objects.
308
- const buf = concatBytes(acc);
309
- await this.client.send(
310
- new PutObjectCommand({
311
- Bucket: this.bucket,
312
- Key: key,
313
- Body: buf,
314
- ContentLength: size,
315
- Metadata: metadata,
316
- }),
317
- );
318
- } else {
319
- // Multipart: upload any remaining bytes as the final part, then complete.
320
- if (accLen > 0) await uploadPart(concatBytes(acc));
321
- await this.client.send(
621
+ // Multipart: upload any remaining bytes as the final part.
622
+ if (uploadId !== null && accLen > 0) {
623
+ await uploadPart(concatBytes(acc));
624
+ acc = [];
625
+ accLen = 0;
626
+ }
627
+ const buf = uploadId === null ? concatBytes(acc) : null;
628
+
629
+ // Creates the object: a single PutObject for small objects, otherwise
630
+ // completes the multipart upload.
631
+ const commit = (condition: WriteCondition): Promise<{ ETag?: string }> => {
632
+ if (buf !== null) {
633
+ return this.client.send(
634
+ new PutObjectCommand({
635
+ Bucket: this.bucket,
636
+ Key: key,
637
+ Body: buf,
638
+ ContentLength: size,
639
+ Metadata: metadataFor(timestamp),
640
+ ...condition,
641
+ }),
642
+ );
643
+ }
644
+ return this.client.send(
322
645
  new CompleteMultipartUploadCommand({
323
646
  Bucket: this.bucket,
324
647
  Key: key,
325
- UploadId: uploadId,
648
+ UploadId: uploadId!,
326
649
  MultipartUpload: { Parts: uploadedParts },
650
+ ...condition,
327
651
  }),
328
652
  );
329
- }
653
+ };
330
654
 
331
- return {
655
+ const info: FileInfo = {
332
656
  level: opts.level,
333
657
  minTXID: opts.minTXID,
334
658
  maxTXID: opts.maxTXID,
335
659
  size,
336
660
  createdAt: timestamp,
337
661
  };
662
+
663
+ if (generation === null) {
664
+ await commit({});
665
+ completed = true;
666
+ return info;
667
+ }
668
+
669
+ // A multipart upload first looks for an existing object, so that the
670
+ // completion is not expected to fail (S3 may not keep the upload open).
671
+ const existing = await this.commitFenced(key, opts, generation, commit, uploadId !== null);
672
+ completed = existing === null;
673
+
674
+ // The upload is only acknowledged if this generation is still the latest
675
+ // after it landed: any generation registered later then sees the file.
676
+ await this.assertGeneration();
677
+ return existing ?? info;
338
678
  } catch (err) {
339
- if (uploadId !== null) {
679
+ if (err instanceof ErrGenerationSuperseded) throw err;
680
+ throw new LTXError('write', key, opts.level, opts.minTXID, opts.maxTXID, err);
681
+ } finally {
682
+ if (uploadId !== null && !completed) {
340
683
  await this.client
341
684
  .send(
342
685
  new AbortMultipartUploadCommand({
@@ -347,15 +690,83 @@ export class S3ReplicaClient implements ReplicaClient {
347
690
  )
348
691
  .catch(() => {});
349
692
  }
350
- throw new LTXError('write', key, opts.level, opts.minTXID, opts.maxTXID, err);
351
693
  }
352
694
  }
353
695
 
696
+ /**
697
+ * Creates a fenced LTX object with `If-None-Match: *`. If the key already
698
+ * exists:
699
+ * - written by a higher generation: this client is superseded;
700
+ * - written by this generation (a retried upload), or by a lower one but
701
+ * still part of the replica: it holds the same transactions, so it is
702
+ * kept and its metadata returned;
703
+ * - otherwise it is a superseded writer's file, replaced with `If-Match`.
704
+ * With `probe`, the key is checked with HeadObject before the first write.
705
+ * Returns null when this call created the object.
706
+ */
707
+ private async commitFenced(
708
+ key: string,
709
+ opts: WriteLTXFileOptions,
710
+ generation: bigint,
711
+ commit: (condition: WriteCondition) => Promise<{ ETag?: string }>,
712
+ probe: boolean,
713
+ ): Promise<FileInfo | null> {
714
+ let condition: WriteCondition = { IfNoneMatch: '*' };
715
+ for (let attempt = 0; attempt < MAX_CONDITIONAL_ATTEMPTS; attempt++) {
716
+ if (!probe) {
717
+ try {
718
+ const res = await commit(condition);
719
+ this.rememberGeneration(key, res.ETag, generation);
720
+ return null;
721
+ } catch (err) {
722
+ if (isConditionalConflict(err)) continue;
723
+ if (!isPreconditionFailed(err)) throw err;
724
+ }
725
+ }
726
+ probe = false;
727
+
728
+ let head;
729
+ try {
730
+ head = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
731
+ } catch (err) {
732
+ if (isNotFound(err)) {
733
+ condition = { IfNoneMatch: '*' }; // deleted meanwhile
734
+ continue;
735
+ }
736
+ throw err;
737
+ }
738
+ const existing = metadataGeneration(head.Metadata);
739
+ if (existing > generation) throw this.superseded(existing);
740
+ const fences = (await this.readGenerationObject())?.state.fences ?? [];
741
+ if (existing === generation || isFenced(fences, existing, opts.maxTXID)) {
742
+ const timestamp = Number(head.Metadata?.['ltx-timestamp']);
743
+ return {
744
+ level: opts.level,
745
+ minTXID: opts.minTXID,
746
+ maxTXID: opts.maxTXID,
747
+ size: Number(head.ContentLength ?? 0),
748
+ createdAt: Number.isFinite(timestamp)
749
+ ? timestamp
750
+ : (head.LastModified ?? new Date(0)).getTime(),
751
+ };
752
+ }
753
+ this.logger.debug('replacing ltx file of a superseded writer', { key, generation: existing });
754
+ condition = { IfMatch: String(head.ETag ?? '') };
755
+ }
756
+ throw new Error(`too many conflicting writes to ${key}`);
757
+ }
758
+
759
+ /**
760
+ * Deletes LTX files. A fenced client first checks that its generation is
761
+ * still the latest and fails with {@link ErrGenerationSuperseded} otherwise
762
+ * (a deletion sent just before a new generation registers still applies).
763
+ */
354
764
  async deleteLtxFiles(infos: FileInfo[]): Promise<void> {
355
765
  const objects = infos.map((info) => ({
356
766
  Key: this.fileKey(info.level, info.minTXID, info.maxTXID),
357
767
  }));
358
768
  for (let i = 0; i < objects.length; i += 1000) {
769
+ await this.assertGeneration();
359
770
  await this.client.send(
360
771
  new DeleteObjectsCommand({
361
772
  Bucket: this.bucket,
@@ -365,16 +776,25 @@ export class S3ReplicaClient implements ReplicaClient {
365
776
  }
366
777
  }
367
778
 
779
+ /**
780
+ * Deletes every object under the prefix except the generation object, so a
781
+ * superseded writer stays fenced. A fenced client checks its generation
782
+ * first, as {@link deleteLtxFiles} does.
783
+ */
368
784
  async deleteAll(): Promise<void> {
369
785
  const prefix = this.prefix ? `${this.prefix}/` : '';
786
+ const generationKey = this.generationKey();
370
787
  let token: string | undefined;
371
788
  do {
372
789
  const res = await this.client.send(
373
790
  new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix, ContinuationToken: token }),
374
791
  );
375
792
  const contents = (res.Contents ?? []) as Array<{ Key?: string }>;
376
- const objects = contents.filter((o) => o.Key).map((o) => ({ Key: o.Key! }));
793
+ const objects = contents
794
+ .filter((o) => o.Key && o.Key !== generationKey)
795
+ .map((o) => ({ Key: o.Key! }));
377
796
  for (let i = 0; i < objects.length; i += 1000) {
797
+ await this.assertGeneration();
378
798
  await this.client.send(
379
799
  new DeleteObjectsCommand({
380
800
  Bucket: this.bucket,