@gscdump/engine 0.32.8 → 0.32.10
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/_chunks/sink.d.mts +20 -4
- package/dist/iceberg/index.mjs +10 -1
- package/package.json +3 -3
package/dist/_chunks/sink.d.mts
CHANGED
|
@@ -285,10 +285,26 @@ interface CommitRetryOptions {
|
|
|
285
285
|
random?: () => number;
|
|
286
286
|
/**
|
|
287
287
|
* Idempotency token stamped into the appended snapshot's summary
|
|
288
|
-
* (`gscdump.append-id`) and matched by the landed-check
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
288
|
+
* (`gscdump.append-id`) and matched by the landed-check. When omitted it is
|
|
289
|
+
* DERIVED from the records' content (a SHA-256 over each row's full
|
|
290
|
+
* key/value, order-independent — see {@link deriveAppendId}), making the
|
|
291
|
+
* token STABLE across PROCESSES, not just within one call's retry loop.
|
|
292
|
+
*
|
|
293
|
+
* Why content-derived (superseding the old "random per call" choice): the
|
|
294
|
+
* double we actually hit is a CROSS-RUN re-append — a job commits the
|
|
295
|
+
* Iceberg snapshot, then dies/evicts before its D1 ledger write, so a queue
|
|
296
|
+
* RETRY (or operator re-resync) re-runs from scratch, re-emits the identical
|
|
297
|
+
* buffer, and appends a second copy. A random per-call id can't catch that
|
|
298
|
+
* (the retry is a new process → new id). A content hash CAN: the retry
|
|
299
|
+
* re-derives the same id, the pre-append landed-check finds it, and skips.
|
|
300
|
+
*
|
|
301
|
+
* The old "content hash risks a false skip / data loss" fear does NOT apply
|
|
302
|
+
* to this fact model: a date is appended exactly once when finalized and
|
|
303
|
+
* never revised (revisions go through the OVERWRITE path), and pagination
|
|
304
|
+
* pages carry DIFFERENT rows → a different content hash → never falsely
|
|
305
|
+
* skipped. Two appends collide only if their full row-sets are byte-identical
|
|
306
|
+
* — which is precisely a duplicate that SHOULD be skipped. Injectable for
|
|
307
|
+
* deterministic tests.
|
|
292
308
|
*/
|
|
293
309
|
appendId?: string;
|
|
294
310
|
}
|
package/dist/iceberg/index.mjs
CHANGED
|
@@ -189,7 +189,7 @@ async function icebergAppendRetrying(args, options = {}) {
|
|
|
189
189
|
const maxDelayMs = options.maxDelayMs ?? 2e4;
|
|
190
190
|
const sleep = options.sleep ?? defaultCommitSleep;
|
|
191
191
|
const random = options.random ?? Math.random;
|
|
192
|
-
const appendId = options.appendId ??
|
|
192
|
+
const appendId = options.appendId ?? await deriveAppendId(args);
|
|
193
193
|
const stampedArgs = {
|
|
194
194
|
...args,
|
|
195
195
|
snapshotProperties: {
|
|
@@ -197,6 +197,7 @@ async function icebergAppendRetrying(args, options = {}) {
|
|
|
197
197
|
[APPEND_ID_SUMMARY_KEY]: appendId
|
|
198
198
|
}
|
|
199
199
|
};
|
|
200
|
+
if (await appendAlreadyLanded(args, appendId).catch(() => false)) return;
|
|
200
201
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
201
202
|
const err = await icebergAppend(stampedArgs).then(() => void 0, (e) => e);
|
|
202
203
|
if (err === void 0) return;
|
|
@@ -206,6 +207,14 @@ async function icebergAppendRetrying(args, options = {}) {
|
|
|
206
207
|
await sleep(Math.floor(random() * ceiling));
|
|
207
208
|
}
|
|
208
209
|
}
|
|
210
|
+
async function deriveAppendId(args) {
|
|
211
|
+
const records = args.records ?? [];
|
|
212
|
+
if (records.length === 0) return globalThis.crypto.randomUUID();
|
|
213
|
+
const rowSig = (r) => Object.keys(r).sort().map((k) => `${k}=${String(r[k])}`).join("");
|
|
214
|
+
const body = records.map(rowSig).sort().join("");
|
|
215
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(body));
|
|
216
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
217
|
+
}
|
|
209
218
|
async function appendAlreadyLanded(args, appendId) {
|
|
210
219
|
const a = args;
|
|
211
220
|
if (a.catalog?.type !== "rest" || a.namespace == null || a.table == null) return false;
|
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.10",
|
|
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": "0.32.
|
|
185
|
-
"
|
|
184
|
+
"@gscdump/contracts": "0.32.10",
|
|
185
|
+
"gscdump": "0.32.10"
|
|
186
186
|
},
|
|
187
187
|
"devDependencies": {
|
|
188
188
|
"@duckdb/duckdb-wasm": "^1.32.0",
|