@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.
@@ -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 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.
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-independentsee {@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
  }
@@ -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 ?? globalThis.crypto.randomUUID();
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.8",
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.8",
185
- "@gscdump/contracts": "0.32.8"
184
+ "@gscdump/contracts": "0.32.10",
185
+ "gscdump": "0.32.10"
186
186
  },
187
187
  "devDependencies": {
188
188
  "@duckdb/duckdb-wasm": "^1.32.0",