@gscdump/engine 0.32.6 → 0.32.7
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.
|
@@ -303,7 +303,8 @@ declare function icebergAppend({
|
|
|
303
303
|
tableUrl,
|
|
304
304
|
resolver,
|
|
305
305
|
records,
|
|
306
|
-
sortOrderId
|
|
306
|
+
sortOrderId,
|
|
307
|
+
snapshotProperties
|
|
307
308
|
}: {
|
|
308
309
|
catalog: Catalog;
|
|
309
310
|
namespace?: string | string[] | undefined;
|
|
@@ -312,6 +313,7 @@ declare function icebergAppend({
|
|
|
312
313
|
resolver?: Resolver | undefined;
|
|
313
314
|
records: Record<string, any>[];
|
|
314
315
|
sortOrderId?: number | undefined;
|
|
316
|
+
snapshotProperties?: Record<string, string> | undefined;
|
|
315
317
|
}): Promise<TableMetadata>;
|
|
316
318
|
/**
|
|
317
319
|
* Create a new table. REST: delegates to the catalog's create endpoint.
|
|
@@ -3216,7 +3216,7 @@ async function prepareAppend({ tableUrl, metadata, records, resolver, sortOrderI
|
|
|
3216
3216
|
writtenFiles: [...writtenDataFiles.map((f) => f.path), manifestPath]
|
|
3217
3217
|
};
|
|
3218
3218
|
}
|
|
3219
|
-
async function stageSnapshotForAppend({ tableUrl, metadata, prepared, resolver }) {
|
|
3219
|
+
async function stageSnapshotForAppend({ tableUrl, metadata, prepared, resolver, snapshotProperties }) {
|
|
3220
3220
|
if (!tableUrl) throw new Error("tableUrl is required");
|
|
3221
3221
|
if (!resolver?.writer) throw new Error("resolver.writer is required");
|
|
3222
3222
|
const sequenceNumber = BigInt(metadata["last-sequence-number"] ?? 0) + 1n;
|
|
@@ -3254,7 +3254,8 @@ async function stageSnapshotForAppend({ tableUrl, metadata, prepared, resolver }
|
|
|
3254
3254
|
"total-data-files": String(prevTotals.files + BigInt(prepared.addedDataFilesCount)),
|
|
3255
3255
|
"total-delete-files": "0",
|
|
3256
3256
|
"total-position-deletes": "0",
|
|
3257
|
-
"total-equality-deletes": "0"
|
|
3257
|
+
"total-equality-deletes": "0",
|
|
3258
|
+
...snapshotProperties ?? {}
|
|
3258
3259
|
};
|
|
3259
3260
|
return await buildSnapshotUpdate({
|
|
3260
3261
|
tableUrl,
|
|
@@ -3370,7 +3371,7 @@ const DEFAULT_RETRY = Object.freeze({
|
|
|
3370
3371
|
factor: 2,
|
|
3371
3372
|
totalTimeoutMs: 1800 * 1e3
|
|
3372
3373
|
});
|
|
3373
|
-
async function icebergAppend({ catalog, namespace, table, tableUrl, resolver, records, sortOrderId }) {
|
|
3374
|
+
async function icebergAppend({ catalog, namespace, table, tableUrl, resolver, records, sortOrderId, snapshotProperties }) {
|
|
3374
3375
|
const ctx = await loadTable({
|
|
3375
3376
|
catalog,
|
|
3376
3377
|
namespace,
|
|
@@ -3396,7 +3397,8 @@ async function icebergAppend({ catalog, namespace, table, tableUrl, resolver, re
|
|
|
3396
3397
|
tableUrl: workingCtx.tableUrl,
|
|
3397
3398
|
metadata: workingCtx.metadata,
|
|
3398
3399
|
prepared,
|
|
3399
|
-
resolver: requireResolver(workingCtx.resolver, "icebergAppend")
|
|
3400
|
+
resolver: requireResolver(workingCtx.resolver, "icebergAppend"),
|
|
3401
|
+
snapshotProperties
|
|
3400
3402
|
})
|
|
3401
3403
|
});
|
|
3402
3404
|
}
|
package/dist/_chunks/sink.d.mts
CHANGED
|
@@ -283,6 +283,14 @@ interface CommitRetryOptions {
|
|
|
283
283
|
sleep?: (ms: number) => Promise<void>;
|
|
284
284
|
/** Injectable RNG for the jitter — tests pass a deterministic value. */
|
|
285
285
|
random?: () => number;
|
|
286
|
+
/**
|
|
287
|
+
* Idempotency token stamped into the appended snapshot's summary
|
|
288
|
+
* (`gscdump.append-id`) and matched by the landed-check before any outer
|
|
289
|
+
* retry. Generated per call (`crypto.randomUUID`) so it is STABLE across this
|
|
290
|
+
* call's retries but UNIQUE per call — a content hash would risk a false
|
|
291
|
+
* "already landed" skip (silent data loss). Injectable for deterministic tests.
|
|
292
|
+
*/
|
|
293
|
+
appendId?: string;
|
|
286
294
|
}
|
|
287
295
|
/**
|
|
288
296
|
* True when `err` is an R2 Data Catalog commit rate-limit response
|
|
@@ -297,15 +305,18 @@ declare function isCommitRateLimited(err: unknown): boolean;
|
|
|
297
305
|
* retries 412/409 internally; 429 is the gap this closes. Non-429 errors
|
|
298
306
|
* (and 429s that survive every attempt) propagate unchanged.
|
|
299
307
|
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
308
|
+
* IDEMPOTENT RE-RUN. A 429 that escapes icebird's internal loop is retried HERE
|
|
309
|
+
* by re-running the whole `icebergAppend`, which re-prepares + re-uploads the
|
|
310
|
+
* data files. That is safe ONLY if the prior attempt's commit did NOT land — but
|
|
311
|
+
* R2 Data Catalog can apply a commit and STILL return 429 (rate-limit on the
|
|
312
|
+
* response path), and a lost ack looks identical. A blind re-run then appends a
|
|
313
|
+
* SECOND copy of the same rows → silent double-count (observed: a re-resynced
|
|
314
|
+
* site read 2× its true totals). To close this, every attempt stamps a per-call
|
|
315
|
+
* `gscdump.append-id` into the snapshot summary, and BEFORE retrying or giving
|
|
316
|
+
* up we reload the table and check whether that id already landed
|
|
317
|
+
* ({@link appendAlreadyLanded}); if so the append succeeded and we return without
|
|
318
|
+
* re-appending. A non-landed 429 still re-runs (the previous attempt's parquet is
|
|
319
|
+
* an orphan, reclaimed by R2 cleanup) — same as before, no double.
|
|
309
320
|
*/
|
|
310
321
|
declare function icebergAppendRetrying(args: Parameters<typeof icebergAppend>[0], options?: CommitRetryOptions): Promise<void>;
|
|
311
322
|
/**
|
package/dist/iceberg/index.mjs
CHANGED
|
@@ -173,6 +173,8 @@ async function connectIcebergCatalog(config, opts = {}) {
|
|
|
173
173
|
namespace: config.namespace
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
|
+
const APPEND_ID_SUMMARY_KEY = "gscdump.append-id";
|
|
177
|
+
const APPEND_LANDED_SCAN_DEPTH = 25;
|
|
176
178
|
function isCommitRateLimited(err) {
|
|
177
179
|
if (err && typeof err === "object" && err.status === 429) return true;
|
|
178
180
|
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
@@ -187,14 +189,35 @@ async function icebergAppendRetrying(args, options = {}) {
|
|
|
187
189
|
const maxDelayMs = options.maxDelayMs ?? 2e4;
|
|
188
190
|
const sleep = options.sleep ?? defaultCommitSleep;
|
|
189
191
|
const random = options.random ?? Math.random;
|
|
192
|
+
const appendId = options.appendId ?? globalThis.crypto.randomUUID();
|
|
193
|
+
const stampedArgs = {
|
|
194
|
+
...args,
|
|
195
|
+
snapshotProperties: {
|
|
196
|
+
...args.snapshotProperties,
|
|
197
|
+
[APPEND_ID_SUMMARY_KEY]: appendId
|
|
198
|
+
}
|
|
199
|
+
};
|
|
190
200
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
191
|
-
const err = await icebergAppend(
|
|
201
|
+
const err = await icebergAppend(stampedArgs).then(() => void 0, (e) => e);
|
|
192
202
|
if (err === void 0) return;
|
|
203
|
+
if (await appendAlreadyLanded(args, appendId).catch(() => false)) return;
|
|
193
204
|
if (!isCommitRateLimited(err) || attempt === maxAttempts - 1) throw err;
|
|
194
205
|
const ceiling = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
|
|
195
206
|
await sleep(Math.floor(random() * ceiling));
|
|
196
207
|
}
|
|
197
208
|
}
|
|
209
|
+
async function appendAlreadyLanded(args, appendId) {
|
|
210
|
+
const a = args;
|
|
211
|
+
if (a.catalog?.type !== "rest" || a.namespace == null || a.table == null) return false;
|
|
212
|
+
const { metadata } = await restCatalogLoadTable(a.catalog, {
|
|
213
|
+
namespace: a.namespace,
|
|
214
|
+
table: a.table
|
|
215
|
+
});
|
|
216
|
+
const snapshots = metadata.snapshots ?? [];
|
|
217
|
+
const from = Math.max(0, snapshots.length - APPEND_LANDED_SCAN_DEPTH);
|
|
218
|
+
for (let i = snapshots.length - 1; i >= from; i--) if (snapshots[i]?.summary?.[APPEND_ID_SUMMARY_KEY] === appendId) return true;
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
198
221
|
async function ensureIcebergNamespace(conn) {
|
|
199
222
|
await restCatalogCreateNamespace(conn.catalog, { namespace: conn.namespace }).catch(() => {});
|
|
200
223
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gscdump/engine",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.32.
|
|
4
|
+
"version": "0.32.7",
|
|
5
5
|
"description": "Append-only Parquet/DuckDB storage engine + planner + adapters for the gscdump pipeline. Node + edge runtimes; opt-in heavy peers.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Harlan Wilton",
|
|
@@ -181,8 +181,8 @@
|
|
|
181
181
|
"dependencies": {
|
|
182
182
|
"drizzle-orm": "1.0.0-rc.3",
|
|
183
183
|
"proper-lockfile": "^4.1.2",
|
|
184
|
-
"@gscdump/contracts": "0.32.
|
|
185
|
-
"gscdump": "0.32.
|
|
184
|
+
"@gscdump/contracts": "0.32.7",
|
|
185
|
+
"gscdump": "0.32.7"
|
|
186
186
|
},
|
|
187
187
|
"devDependencies": {
|
|
188
188
|
"@duckdb/duckdb-wasm": "^1.32.0",
|