@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/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 +118 -0
- package/dist/replica-client.d.ts.map +1 -1
- package/dist/replica-client.js +404 -30
- 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 +5 -0
- package/src/leaser.ts +1 -54
- package/src/replica-client.ts +451 -31
- package/src/util.ts +68 -0
package/dist/replica-client.js
CHANGED
|
@@ -10,12 +10,92 @@
|
|
|
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 { 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
|
+
/** Retries of a conditional write that keeps conflicting with concurrent ones. */
|
|
56
|
+
const MAX_CONDITIONAL_ATTEMPTS = 10;
|
|
57
|
+
/** HeadObject requests in flight while resolving the generation of listed files. */
|
|
58
|
+
const HEAD_CONCURRENCY = 16;
|
|
59
|
+
/** Cached generations of listed objects (by key and ETag). */
|
|
60
|
+
const GENERATION_CACHE_SIZE = 10_000;
|
|
61
|
+
/** Parses the `ltx-generation` metadata (0 when absent or invalid). */
|
|
62
|
+
function metadataGeneration(metadata) {
|
|
63
|
+
const v = metadata?.[GenerationMetadataKey];
|
|
64
|
+
if (v === undefined || !/^\d+$/.test(v))
|
|
65
|
+
return 0n;
|
|
66
|
+
return BigInt(v);
|
|
67
|
+
}
|
|
68
|
+
/** Serializes a generation state (bigints as decimal strings). */
|
|
69
|
+
function encodeGenerationState(state) {
|
|
70
|
+
return new TextEncoder().encode(JSON.stringify({
|
|
71
|
+
generation: state.generation.toString(),
|
|
72
|
+
fences: state.fences.map((f) => ({ generation: f.generation.toString(), txid: f.txid.toString() })),
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
/** Parses a generation object body. */
|
|
76
|
+
function decodeGenerationState(data) {
|
|
77
|
+
const parsed = JSON.parse(new TextDecoder().decode(data));
|
|
78
|
+
return {
|
|
79
|
+
generation: BigInt(parsed.generation ?? '0'),
|
|
80
|
+
fences: (parsed.fences ?? []).map((f) => ({
|
|
81
|
+
generation: BigInt(f.generation ?? '0'),
|
|
82
|
+
txid: BigInt(f.txid ?? '0'),
|
|
83
|
+
})),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Runs `fn` over `items` with at most `limit` calls in flight, preserving order. */
|
|
87
|
+
async function mapLimit(items, limit, fn) {
|
|
88
|
+
const out = new Array(items.length);
|
|
89
|
+
let next = 0;
|
|
90
|
+
const worker = async () => {
|
|
91
|
+
while (next < items.length) {
|
|
92
|
+
const i = next++;
|
|
93
|
+
out[i] = await fn(items[i]);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
19
99
|
/** Normalized base prefix (no leading/trailing slash, `''` when empty). */
|
|
20
100
|
function normalizePrefix(prefix) {
|
|
21
101
|
return prefix.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
@@ -41,12 +121,18 @@ export class S3ReplicaClient {
|
|
|
41
121
|
prefix;
|
|
42
122
|
client;
|
|
43
123
|
logger = noopLogger;
|
|
124
|
+
generation;
|
|
125
|
+
/** The higher generation that superseded this client, once observed. */
|
|
126
|
+
supersededBy = null;
|
|
127
|
+
/** Generation of listed objects, keyed by `key` + ETag (objects are immutable). */
|
|
128
|
+
objectGenerations = new Map();
|
|
44
129
|
constructor(opts) {
|
|
45
130
|
if (!opts.bucket) {
|
|
46
131
|
throw new Error('S3 replica client: bucket is required');
|
|
47
132
|
}
|
|
48
133
|
this.bucket = opts.bucket;
|
|
49
134
|
this.prefix = normalizePrefix(opts.prefix ?? '');
|
|
135
|
+
this.generation = opts.generation ?? null;
|
|
50
136
|
this.client =
|
|
51
137
|
opts.client ??
|
|
52
138
|
new S3Client({
|
|
@@ -88,8 +174,169 @@ export class S3ReplicaClient {
|
|
|
88
174
|
fileKey(level, minTXID, maxTXID) {
|
|
89
175
|
return `${this.levelPrefix(level)}${formatFilename(minTXID, maxTXID)}`;
|
|
90
176
|
}
|
|
177
|
+
/** Returns the key of the generation object. */
|
|
178
|
+
generationKey() {
|
|
179
|
+
return `${this.prefix ? `${this.prefix}/` : ''}${GenerationObjectName}`;
|
|
180
|
+
}
|
|
181
|
+
/** Current fenced-write generation, or `null` when fencing is disabled. */
|
|
182
|
+
getGeneration() {
|
|
183
|
+
return this.generation;
|
|
184
|
+
}
|
|
185
|
+
/** Reads the generation object, or returns `null` if fencing was never used. */
|
|
186
|
+
async getGenerationState() {
|
|
187
|
+
return (await this.readGenerationObject())?.state ?? null;
|
|
188
|
+
}
|
|
189
|
+
async readGenerationObject() {
|
|
190
|
+
let res;
|
|
191
|
+
try {
|
|
192
|
+
res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: this.generationKey() }));
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
if (isNotFound(err))
|
|
196
|
+
return null;
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
const state = decodeGenerationState(await readBody(res.Body));
|
|
200
|
+
return { state, etag: String(res.ETag ?? '') };
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Writes the generation object if it was not changed since it was read with
|
|
204
|
+
* `etag` (`null`: if it does not exist). Returns false on a lost race.
|
|
205
|
+
*/
|
|
206
|
+
async writeGenerationObject(state, etag) {
|
|
207
|
+
try {
|
|
208
|
+
await this.client.send(new PutObjectCommand({
|
|
209
|
+
Bucket: this.bucket,
|
|
210
|
+
Key: this.generationKey(),
|
|
211
|
+
Body: encodeGenerationState(state),
|
|
212
|
+
ContentType: 'application/json',
|
|
213
|
+
...(etag === null ? { IfNoneMatch: '*' } : { IfMatch: etag }),
|
|
214
|
+
}));
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
if (isPreconditionFailed(err) || isConditionalConflict(err))
|
|
219
|
+
return false;
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Records that this client was superseded by `generation` and returns the error. */
|
|
224
|
+
superseded(generation) {
|
|
225
|
+
this.supersededBy = generation;
|
|
226
|
+
return new ErrGenerationSuperseded(generation);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Fails with {@link ErrGenerationSuperseded} if a generation higher than this
|
|
230
|
+
* client's is registered. A no-op when fencing is disabled.
|
|
231
|
+
*/
|
|
232
|
+
async assertGeneration() {
|
|
233
|
+
if (this.generation === null)
|
|
234
|
+
return;
|
|
235
|
+
if (this.supersededBy !== null)
|
|
236
|
+
throw new ErrGenerationSuperseded(this.supersededBy);
|
|
237
|
+
const cur = await this.readGenerationObject();
|
|
238
|
+
if (cur !== null && cur.state.generation > this.generation) {
|
|
239
|
+
throw this.superseded(cur.state.generation);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Raises the replica's generation to `generation`, failing with
|
|
244
|
+
* {@link ErrGenerationSuperseded} if a higher one is already registered, and
|
|
245
|
+
* records a fence at the replica's latest TXID as read after the
|
|
246
|
+
* registration. From then on, files of lower generations above that TXID
|
|
247
|
+
* are ignored by every listing, and uploads and deletions of lower
|
|
248
|
+
* generations fail once they observe the new generation.
|
|
249
|
+
*
|
|
250
|
+
* Call it before restoring: the restore then sees every file that a former
|
|
251
|
+
* writer can still make part of the replica. Registering the current
|
|
252
|
+
* generation again is a no-op that keeps its fence.
|
|
253
|
+
*/
|
|
254
|
+
async registerGeneration(generation) {
|
|
255
|
+
// 1. Raise the generation (compare-and-swap on the generation object).
|
|
256
|
+
for (let attempt = 0;; attempt++) {
|
|
257
|
+
if (attempt >= MAX_CONDITIONAL_ATTEMPTS) {
|
|
258
|
+
throw new Error('S3 replica client: too many conflicts registering the generation');
|
|
259
|
+
}
|
|
260
|
+
const cur = await this.readGenerationObject();
|
|
261
|
+
if (cur !== null && cur.state.generation > generation) {
|
|
262
|
+
throw new ErrGenerationSuperseded(cur.state.generation);
|
|
263
|
+
}
|
|
264
|
+
if (cur !== null && cur.state.generation === generation)
|
|
265
|
+
break;
|
|
266
|
+
const next = { generation, fences: cur?.state.fences ?? [] };
|
|
267
|
+
if (await this.writeGenerationObject(next, cur?.etag ?? null))
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
this.generation = generation;
|
|
271
|
+
this.supersededBy = null;
|
|
272
|
+
// 2. Read the latest TXID of the replica. Files of lower generations that
|
|
273
|
+
// land from now on and end above it are not part of the replica.
|
|
274
|
+
let txid = 0n;
|
|
275
|
+
for (let level = 0; level <= SnapshotLevel; level++) {
|
|
276
|
+
for await (const info of this.ltxFiles({ level, seek: 0n, useMetadata: false })) {
|
|
277
|
+
if (info.maxTXID > txid)
|
|
278
|
+
txid = info.maxTXID;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// 3. Record the fence, unless a higher generation registered meanwhile.
|
|
282
|
+
for (let attempt = 0;; attempt++) {
|
|
283
|
+
if (attempt >= MAX_CONDITIONAL_ATTEMPTS) {
|
|
284
|
+
throw new Error('S3 replica client: too many conflicts recording the generation fence');
|
|
285
|
+
}
|
|
286
|
+
const cur = await this.readGenerationObject();
|
|
287
|
+
const stored = cur?.state.generation ?? generation;
|
|
288
|
+
if (stored > generation)
|
|
289
|
+
throw this.superseded(stored);
|
|
290
|
+
const fences = cur?.state.fences ?? [];
|
|
291
|
+
if (fences.some((f) => f.generation === generation))
|
|
292
|
+
break;
|
|
293
|
+
const next = { generation, fences: [...fences, { generation, txid }] };
|
|
294
|
+
if (await this.writeGenerationObject(next, cur?.etag ?? null))
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
this.logger.debug('registered generation', { generation, txid });
|
|
298
|
+
}
|
|
299
|
+
/** Returns the generation of an object (from `ltx-generation`), or null if it is gone. */
|
|
300
|
+
async objectGeneration(key, etag) {
|
|
301
|
+
const cacheKey = etag ? `${key}\n${etag}` : null;
|
|
302
|
+
if (cacheKey !== null) {
|
|
303
|
+
const cached = this.objectGenerations.get(cacheKey);
|
|
304
|
+
if (cached !== undefined)
|
|
305
|
+
return cached;
|
|
306
|
+
}
|
|
307
|
+
let head;
|
|
308
|
+
try {
|
|
309
|
+
head = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
if (isNotFound(err))
|
|
313
|
+
return null;
|
|
314
|
+
throw err;
|
|
315
|
+
}
|
|
316
|
+
const generation = metadataGeneration(head.Metadata);
|
|
317
|
+
this.rememberGeneration(key, head.ETag ?? etag, generation);
|
|
318
|
+
return generation;
|
|
319
|
+
}
|
|
320
|
+
rememberGeneration(key, etag, generation) {
|
|
321
|
+
if (!etag)
|
|
322
|
+
return;
|
|
323
|
+
if (this.objectGenerations.size >= GENERATION_CACHE_SIZE)
|
|
324
|
+
this.objectGenerations.clear();
|
|
325
|
+
this.objectGenerations.set(`${key}\n${etag}`, generation);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Lists the LTX files of a level. When the replica has fences, files ending
|
|
329
|
+
* above the lowest fence need their generation (a HeadObject request, cached
|
|
330
|
+
* per key and ETag), and those written by a superseded writer are skipped.
|
|
331
|
+
*/
|
|
91
332
|
async *ltxFiles(opts) {
|
|
92
333
|
const prefix = this.levelPrefix(opts.level);
|
|
334
|
+
const fences = (await this.readGenerationObject())?.state.fences ?? [];
|
|
335
|
+
let lowestFence = null;
|
|
336
|
+
for (const f of fences) {
|
|
337
|
+
if (lowestFence === null || f.txid < lowestFence)
|
|
338
|
+
lowestFence = f.txid;
|
|
339
|
+
}
|
|
93
340
|
let token;
|
|
94
341
|
do {
|
|
95
342
|
const res = await this.client.send(new ListObjectsV2Command({
|
|
@@ -97,6 +344,7 @@ export class S3ReplicaClient {
|
|
|
97
344
|
Prefix: prefix,
|
|
98
345
|
ContinuationToken: token,
|
|
99
346
|
}));
|
|
347
|
+
const page = [];
|
|
100
348
|
for (const obj of res.Contents ?? []) {
|
|
101
349
|
if (!obj.Key)
|
|
102
350
|
continue;
|
|
@@ -111,13 +359,31 @@ export class S3ReplicaClient {
|
|
|
111
359
|
}
|
|
112
360
|
if (minTXID < opts.seek)
|
|
113
361
|
continue;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
362
|
+
page.push({
|
|
363
|
+
key: obj.Key,
|
|
364
|
+
etag: obj.ETag,
|
|
365
|
+
info: {
|
|
366
|
+
level: opts.level,
|
|
367
|
+
minTXID,
|
|
368
|
+
maxTXID,
|
|
369
|
+
size: obj.Size ?? 0,
|
|
370
|
+
createdAt: (obj.LastModified ?? new Date(0)).getTime(),
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
let visible = page.map(() => true);
|
|
375
|
+
if (lowestFence !== null) {
|
|
376
|
+
const fence = lowestFence;
|
|
377
|
+
visible = await mapLimit(page, HEAD_CONCURRENCY, async (p) => {
|
|
378
|
+
if (p.info.maxTXID <= fence)
|
|
379
|
+
return true;
|
|
380
|
+
const generation = await this.objectGeneration(p.key, p.etag);
|
|
381
|
+
return generation !== null && isFenced(fences, generation, p.info.maxTXID);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
for (let i = 0; i < page.length; i++) {
|
|
385
|
+
if (visible[i])
|
|
386
|
+
yield page[i].info;
|
|
121
387
|
}
|
|
122
388
|
token = res.IsTruncated ? (res.NextContinuationToken ?? undefined) : undefined;
|
|
123
389
|
} while (token);
|
|
@@ -162,6 +428,10 @@ export class S3ReplicaClient {
|
|
|
162
428
|
}
|
|
163
429
|
async writeLtxFile(opts, body) {
|
|
164
430
|
const key = this.fileKey(opts.level, opts.minTXID, opts.maxTXID);
|
|
431
|
+
const generation = this.generation;
|
|
432
|
+
if (generation !== null && this.supersededBy !== null) {
|
|
433
|
+
throw new ErrGenerationSuperseded(this.supersededBy);
|
|
434
|
+
}
|
|
165
435
|
// Capture the LTX header (timestamp) from the leading bytes, and stream the
|
|
166
436
|
// rest. Small objects use a single PutObject; large objects (> 5 MiB) are
|
|
167
437
|
// uploaded with the multipart API so the whole file is never buffered.
|
|
@@ -172,6 +442,7 @@ export class S3ReplicaClient {
|
|
|
172
442
|
let accLen = 0;
|
|
173
443
|
let size = 0;
|
|
174
444
|
let uploadId = null;
|
|
445
|
+
let completed = false;
|
|
175
446
|
let partNumber = 0;
|
|
176
447
|
const uploadedParts = [];
|
|
177
448
|
const uploadPart = async (buf) => {
|
|
@@ -185,6 +456,12 @@ export class S3ReplicaClient {
|
|
|
185
456
|
}));
|
|
186
457
|
uploadedParts.push({ PartNumber: partNumber, ETag: res.ETag ?? `etag-${partNumber}` });
|
|
187
458
|
};
|
|
459
|
+
const metadataFor = (timestamp) => {
|
|
460
|
+
const metadata = { 'ltx-timestamp': String(timestamp) };
|
|
461
|
+
if (generation !== null)
|
|
462
|
+
metadata[GenerationMetadataKey] = generation.toString();
|
|
463
|
+
return metadata;
|
|
464
|
+
};
|
|
188
465
|
try {
|
|
189
466
|
for await (const chunk of body) {
|
|
190
467
|
const bytes = typeof chunk === 'string' ? new TextEncoder().encode(chunk) : Uint8Array.from(chunk);
|
|
@@ -206,7 +483,7 @@ export class S3ReplicaClient {
|
|
|
206
483
|
const res = await this.client.send(new CreateMultipartUploadCommand({
|
|
207
484
|
Bucket: this.bucket,
|
|
208
485
|
Key: key,
|
|
209
|
-
Metadata:
|
|
486
|
+
Metadata: metadataFor(Number(unmarshalHeader(headerBytes).timestamp)),
|
|
210
487
|
}));
|
|
211
488
|
uploadId = res.UploadId;
|
|
212
489
|
await uploadPart(concatBytes(acc));
|
|
@@ -223,39 +500,62 @@ export class S3ReplicaClient {
|
|
|
223
500
|
throw new Error('write ltx file: input too short to contain a header');
|
|
224
501
|
}
|
|
225
502
|
const timestamp = Number(unmarshalHeader(headerBytes).timestamp);
|
|
226
|
-
|
|
227
|
-
if (uploadId
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
Bucket: this.bucket,
|
|
232
|
-
Key: key,
|
|
233
|
-
Body: buf,
|
|
234
|
-
ContentLength: size,
|
|
235
|
-
Metadata: metadata,
|
|
236
|
-
}));
|
|
503
|
+
// Multipart: upload any remaining bytes as the final part.
|
|
504
|
+
if (uploadId !== null && accLen > 0) {
|
|
505
|
+
await uploadPart(concatBytes(acc));
|
|
506
|
+
acc = [];
|
|
507
|
+
accLen = 0;
|
|
237
508
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
509
|
+
const buf = uploadId === null ? concatBytes(acc) : null;
|
|
510
|
+
// Creates the object: a single PutObject for small objects, otherwise
|
|
511
|
+
// completes the multipart upload.
|
|
512
|
+
const commit = (condition) => {
|
|
513
|
+
if (buf !== null) {
|
|
514
|
+
return this.client.send(new PutObjectCommand({
|
|
515
|
+
Bucket: this.bucket,
|
|
516
|
+
Key: key,
|
|
517
|
+
Body: buf,
|
|
518
|
+
ContentLength: size,
|
|
519
|
+
Metadata: metadataFor(timestamp),
|
|
520
|
+
...condition,
|
|
521
|
+
}));
|
|
522
|
+
}
|
|
523
|
+
return this.client.send(new CompleteMultipartUploadCommand({
|
|
243
524
|
Bucket: this.bucket,
|
|
244
525
|
Key: key,
|
|
245
526
|
UploadId: uploadId,
|
|
246
527
|
MultipartUpload: { Parts: uploadedParts },
|
|
528
|
+
...condition,
|
|
247
529
|
}));
|
|
248
|
-
}
|
|
249
|
-
|
|
530
|
+
};
|
|
531
|
+
const info = {
|
|
250
532
|
level: opts.level,
|
|
251
533
|
minTXID: opts.minTXID,
|
|
252
534
|
maxTXID: opts.maxTXID,
|
|
253
535
|
size,
|
|
254
536
|
createdAt: timestamp,
|
|
255
537
|
};
|
|
538
|
+
if (generation === null) {
|
|
539
|
+
await commit({});
|
|
540
|
+
completed = true;
|
|
541
|
+
return info;
|
|
542
|
+
}
|
|
543
|
+
// A multipart upload first looks for an existing object, so that the
|
|
544
|
+
// completion is not expected to fail (S3 may not keep the upload open).
|
|
545
|
+
const existing = await this.commitFenced(key, opts, generation, commit, uploadId !== null);
|
|
546
|
+
completed = existing === null;
|
|
547
|
+
// The upload is only acknowledged if this generation is still the latest
|
|
548
|
+
// after it landed: any generation registered later then sees the file.
|
|
549
|
+
await this.assertGeneration();
|
|
550
|
+
return existing ?? info;
|
|
256
551
|
}
|
|
257
552
|
catch (err) {
|
|
258
|
-
if (
|
|
553
|
+
if (err instanceof ErrGenerationSuperseded)
|
|
554
|
+
throw err;
|
|
555
|
+
throw new LTXError('write', key, opts.level, opts.minTXID, opts.maxTXID, err);
|
|
556
|
+
}
|
|
557
|
+
finally {
|
|
558
|
+
if (uploadId !== null && !completed) {
|
|
259
559
|
await this.client
|
|
260
560
|
.send(new AbortMultipartUploadCommand({
|
|
261
561
|
Bucket: this.bucket,
|
|
@@ -264,28 +564,102 @@ export class S3ReplicaClient {
|
|
|
264
564
|
}))
|
|
265
565
|
.catch(() => { });
|
|
266
566
|
}
|
|
267
|
-
throw new LTXError('write', key, opts.level, opts.minTXID, opts.maxTXID, err);
|
|
268
567
|
}
|
|
269
568
|
}
|
|
569
|
+
/**
|
|
570
|
+
* Creates a fenced LTX object with `If-None-Match: *`. If the key already
|
|
571
|
+
* exists:
|
|
572
|
+
* - written by a higher generation: this client is superseded;
|
|
573
|
+
* - written by this generation (a retried upload), or by a lower one but
|
|
574
|
+
* still part of the replica: it holds the same transactions, so it is
|
|
575
|
+
* kept and its metadata returned;
|
|
576
|
+
* - otherwise it is a superseded writer's file, replaced with `If-Match`.
|
|
577
|
+
* With `probe`, the key is checked with HeadObject before the first write.
|
|
578
|
+
* Returns null when this call created the object.
|
|
579
|
+
*/
|
|
580
|
+
async commitFenced(key, opts, generation, commit, probe) {
|
|
581
|
+
let condition = { IfNoneMatch: '*' };
|
|
582
|
+
for (let attempt = 0; attempt < MAX_CONDITIONAL_ATTEMPTS; attempt++) {
|
|
583
|
+
if (!probe) {
|
|
584
|
+
try {
|
|
585
|
+
const res = await commit(condition);
|
|
586
|
+
this.rememberGeneration(key, res.ETag, generation);
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
if (isConditionalConflict(err))
|
|
591
|
+
continue;
|
|
592
|
+
if (!isPreconditionFailed(err))
|
|
593
|
+
throw err;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
probe = false;
|
|
597
|
+
let head;
|
|
598
|
+
try {
|
|
599
|
+
head = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
600
|
+
}
|
|
601
|
+
catch (err) {
|
|
602
|
+
if (isNotFound(err)) {
|
|
603
|
+
condition = { IfNoneMatch: '*' }; // deleted meanwhile
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
throw err;
|
|
607
|
+
}
|
|
608
|
+
const existing = metadataGeneration(head.Metadata);
|
|
609
|
+
if (existing > generation)
|
|
610
|
+
throw this.superseded(existing);
|
|
611
|
+
const fences = (await this.readGenerationObject())?.state.fences ?? [];
|
|
612
|
+
if (existing === generation || isFenced(fences, existing, opts.maxTXID)) {
|
|
613
|
+
const timestamp = Number(head.Metadata?.['ltx-timestamp']);
|
|
614
|
+
return {
|
|
615
|
+
level: opts.level,
|
|
616
|
+
minTXID: opts.minTXID,
|
|
617
|
+
maxTXID: opts.maxTXID,
|
|
618
|
+
size: Number(head.ContentLength ?? 0),
|
|
619
|
+
createdAt: Number.isFinite(timestamp)
|
|
620
|
+
? timestamp
|
|
621
|
+
: (head.LastModified ?? new Date(0)).getTime(),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
this.logger.debug('replacing ltx file of a superseded writer', { key, generation: existing });
|
|
625
|
+
condition = { IfMatch: String(head.ETag ?? '') };
|
|
626
|
+
}
|
|
627
|
+
throw new Error(`too many conflicting writes to ${key}`);
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Deletes LTX files. A fenced client first checks that its generation is
|
|
631
|
+
* still the latest and fails with {@link ErrGenerationSuperseded} otherwise
|
|
632
|
+
* (a deletion sent just before a new generation registers still applies).
|
|
633
|
+
*/
|
|
270
634
|
async deleteLtxFiles(infos) {
|
|
271
635
|
const objects = infos.map((info) => ({
|
|
272
636
|
Key: this.fileKey(info.level, info.minTXID, info.maxTXID),
|
|
273
637
|
}));
|
|
274
638
|
for (let i = 0; i < objects.length; i += 1000) {
|
|
639
|
+
await this.assertGeneration();
|
|
275
640
|
await this.client.send(new DeleteObjectsCommand({
|
|
276
641
|
Bucket: this.bucket,
|
|
277
642
|
Delete: { Objects: objects.slice(i, i + 1000), Quiet: true },
|
|
278
643
|
}));
|
|
279
644
|
}
|
|
280
645
|
}
|
|
646
|
+
/**
|
|
647
|
+
* Deletes every object under the prefix except the generation object, so a
|
|
648
|
+
* superseded writer stays fenced. A fenced client checks its generation
|
|
649
|
+
* first, as {@link deleteLtxFiles} does.
|
|
650
|
+
*/
|
|
281
651
|
async deleteAll() {
|
|
282
652
|
const prefix = this.prefix ? `${this.prefix}/` : '';
|
|
653
|
+
const generationKey = this.generationKey();
|
|
283
654
|
let token;
|
|
284
655
|
do {
|
|
285
656
|
const res = await this.client.send(new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix, ContinuationToken: token }));
|
|
286
657
|
const contents = (res.Contents ?? []);
|
|
287
|
-
const objects = contents
|
|
658
|
+
const objects = contents
|
|
659
|
+
.filter((o) => o.Key && o.Key !== generationKey)
|
|
660
|
+
.map((o) => ({ Key: o.Key }));
|
|
288
661
|
for (let i = 0; i < objects.length; i += 1000) {
|
|
662
|
+
await this.assertGeneration();
|
|
289
663
|
await this.client.send(new DeleteObjectsCommand({
|
|
290
664
|
Bucket: this.bucket,
|
|
291
665
|
Delete: { Objects: objects.slice(i, i + 1000), Quiet: true },
|
package/dist/util.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers shared by the S3 replica client and leaser: body reading and the
|
|
3
|
+
* classification of S3 errors (404, 412, 409).
|
|
4
|
+
*/
|
|
5
|
+
/** Reads all bytes from an S3 body (stream or Buffer). */
|
|
6
|
+
export declare function readBody(body: any): Promise<Uint8Array>;
|
|
7
|
+
export declare function isPreconditionFailed(err: unknown): boolean;
|
|
8
|
+
export declare function isNotFound(err: unknown): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* A concurrent conditional write to the same key (`409 ConditionalRequestConflict`).
|
|
11
|
+
* S3 asks the caller to retry the request.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isConditionalConflict(err: unknown): boolean;
|
|
14
|
+
//# sourceMappingURL=util.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,0DAA0D;AAC1D,wBAAsB,QAAQ,CAAC,IAAI,EAAE,GAAG,GAAG,OAAO,CAAC,UAAU,CAAC,CAuC7D;AAED,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAI1D;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAIhD;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAI3D"}
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers shared by the S3 replica client and leaser: body reading and the
|
|
3
|
+
* classification of S3 errors (404, 412, 409).
|
|
4
|
+
*/
|
|
5
|
+
/** Reads all bytes from an S3 body (stream or Buffer). */
|
|
6
|
+
export async function readBody(body) {
|
|
7
|
+
// AWS SDK v3 returns a mix; normalize to a Uint8Array.
|
|
8
|
+
if (body instanceof Uint8Array)
|
|
9
|
+
return body;
|
|
10
|
+
if (typeof body?.on === 'function') {
|
|
11
|
+
// Node stream.
|
|
12
|
+
const chunks = [];
|
|
13
|
+
for await (const chunk of body) {
|
|
14
|
+
chunks.push(typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk);
|
|
15
|
+
}
|
|
16
|
+
let total = 0;
|
|
17
|
+
for (const c of chunks)
|
|
18
|
+
total += c.length;
|
|
19
|
+
const out = new Uint8Array(total);
|
|
20
|
+
let o = 0;
|
|
21
|
+
for (const c of chunks) {
|
|
22
|
+
out.set(c, o);
|
|
23
|
+
o += c.length;
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
// Web ReadableStream or Buffer.
|
|
28
|
+
if (typeof body?.getReader === 'function') {
|
|
29
|
+
const reader = body.getReader();
|
|
30
|
+
const chunks = [];
|
|
31
|
+
for (;;) {
|
|
32
|
+
const { done, value } = await reader.read();
|
|
33
|
+
if (done)
|
|
34
|
+
break;
|
|
35
|
+
chunks.push(value);
|
|
36
|
+
}
|
|
37
|
+
let total = 0;
|
|
38
|
+
for (const c of chunks)
|
|
39
|
+
total += c.length;
|
|
40
|
+
const out = new Uint8Array(total);
|
|
41
|
+
let o = 0;
|
|
42
|
+
for (const c of chunks) {
|
|
43
|
+
out.set(c, o);
|
|
44
|
+
o += c.length;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
return new Uint8Array(body);
|
|
49
|
+
}
|
|
50
|
+
export function isPreconditionFailed(err) {
|
|
51
|
+
const code = err?.$metadata?.httpStatusCode;
|
|
52
|
+
const name = err?.name ?? '';
|
|
53
|
+
return code === 412 || name === 'PreconditionFailed';
|
|
54
|
+
}
|
|
55
|
+
export function isNotFound(err) {
|
|
56
|
+
const code = err?.$metadata?.httpStatusCode;
|
|
57
|
+
const name = err?.name ?? '';
|
|
58
|
+
return code === 404 || name === 'NoSuchKey' || name === 'NotFound';
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A concurrent conditional write to the same key (`409 ConditionalRequestConflict`).
|
|
62
|
+
* S3 asks the caller to retry the request.
|
|
63
|
+
*/
|
|
64
|
+
export function isConditionalConflict(err) {
|
|
65
|
+
const code = err?.$metadata?.httpStatusCode;
|
|
66
|
+
const name = err?.name ?? '';
|
|
67
|
+
return code === 409 || name === 'ConditionalRequestConflict';
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platformatic/sqlite-js-stream-s3",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Amazon S3 upload strategy (replica client) for @platformatic/sqlite-js-stream",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@aws-sdk/client-s3": "^3.1135.0",
|
|
40
|
-
"@platformatic/sqlite-js-stream": "0.
|
|
40
|
+
"@platformatic/sqlite-js-stream": "0.3.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@types/node": "^
|
|
43
|
+
"@types/node": "^26.0.0",
|
|
44
44
|
"typescript": "^5.7.0"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
export {
|
|
10
10
|
S3ReplicaClient,
|
|
11
11
|
newReplicaClientFromURL,
|
|
12
|
+
isFenced,
|
|
13
|
+
GenerationMetadataKey,
|
|
14
|
+
GenerationObjectName,
|
|
12
15
|
ReplicaClientType,
|
|
16
|
+
type GenerationFence,
|
|
17
|
+
type GenerationState,
|
|
13
18
|
type S3ClientLike,
|
|
14
19
|
type S3ReplicaClientOptions,
|
|
15
20
|
} from './replica-client.ts';
|