@gscdump/lakehouse 5.9.0 → 6.0.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/README.md CHANGED
@@ -45,6 +45,27 @@ For retry decisions, import `isCommitRateLimited`, `isCommitServerError`, or
45
45
  Use the dataset registry to define datasets.
46
46
  Raw Icebird table creation and append functions live under `unsafe-raw` for package adapters and diagnostics.
47
47
 
48
+ ## Append retries
49
+
50
+ Supply a durable `commitRetry.appendId` when a dataset append belongs to a job or receipt.
51
+ Reuse that ID when the job retries, including retries after package upgrades.
52
+
53
+ Without an explicit ID, row appends derive a versioned ID from framed, typed record content.
54
+ Row order and object key order do not change the ID.
55
+
56
+ If only a legacy content ID matches, the append throws `LegacyAppendIdentityUnverifiable`.
57
+ The error includes `legacyAppendId`.
58
+ The legacy ID cannot prove whether the committed rows match the retry.
59
+ Verify the committed rows before supplying that legacy ID as an explicit `appendId`.
60
+ If the rows differ, use a new durable ID for the new append.
61
+ Do not retry with the error's legacy ID without checking the rows.
62
+
63
+ Before upgrading producers that derive IDs, drain their existing write tasks.
64
+ Do not run old and new derived-ID writers concurrently against the same table.
65
+ Older writers cannot recognize the new content ID format.
66
+
67
+ Batch appends already require an explicit `appendId` and retain that behavior.
68
+
48
69
  ## License
49
70
 
50
71
  [MIT](../../LICENSE)
@@ -0,0 +1,42 @@
1
+ function encodeValue(value) {
2
+ if (value === null) return "null";
3
+ if (typeof value !== "object") {
4
+ if (typeof value === "function" || typeof value === "symbol") throw new TypeError("Append records contain an unsupported value.");
5
+ return `${typeof value}:${Object.is(value, -0) ? "-0" : String(value)}`;
6
+ }
7
+ if (value instanceof Date) return `date:${value.toISOString()}`;
8
+ if (value instanceof Uint8Array) return `bytes:${Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
9
+ if (Array.isArray(value)) return ["array", Array.from(value, encodeValue)];
10
+ if (value instanceof Map) return ["map", Array.from(value, ([key, entry]) => JSON.stringify([encodeValue(key), encodeValue(entry)])).sort()];
11
+ if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new TypeError("Append records contain an unsupported value.");
12
+ return ["object", Object.keys(value).sort().map((key) => [key, encodeValue(value[key])])];
13
+ }
14
+ async function hashContent(content) {
15
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(content));
16
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
17
+ }
18
+ async function deriveAppendIdentity(records) {
19
+ if (!records.length) return {
20
+ _tag: "Explicit",
21
+ appendId: crypto.randomUUID()
22
+ };
23
+ const legacyAppendId = await hashContent(records.map((record) => Object.keys(record).sort().map((key) => `${key}=${String(record[key])}`).join("")).sort().join(""));
24
+ const groups = /* @__PURE__ */ new Map();
25
+ for (const record of records) {
26
+ if (Object.getPrototypeOf(record) !== Object.prototype && Object.getPrototypeOf(record) !== null) throw new TypeError("Append records contain an unsupported value.");
27
+ const keys = Object.keys(record).sort();
28
+ const schema = JSON.stringify(keys);
29
+ const row = JSON.stringify(keys.map((key) => encodeValue(record[key])));
30
+ const group = groups.get(schema);
31
+ if (group) group.push(row);
32
+ else groups.set(schema, [row]);
33
+ }
34
+ const content = `[${Array.from(groups, ([schema, rows]) => `[${schema},[${rows.sort().join(",")}]]`).sort().join(",")}]`;
35
+ groups.clear();
36
+ return {
37
+ _tag: "Content",
38
+ appendId: `content-v2:${await hashContent(content)}`,
39
+ legacyAppendId
40
+ };
41
+ }
42
+ export { deriveAppendIdentity };
@@ -153,8 +153,9 @@ export interface CommitRetryOptions {
153
153
  /**
154
154
  * Idempotency token stamped into the appended snapshot's summary
155
155
  * (`lakehouse.append-id`) and matched by the landed-check. When omitted it is
156
- * DERIVED from the records' content, making the token STABLE across
157
- * PROCESSES, not just within one call's retry loop.
156
+ * Derived from framed, typed record content when omitted. Row and object
157
+ * key order do not change the token. A matching legacy content token throws
158
+ * `LegacyAppendIdentityUnverifiable` until committed rows are verified.
158
159
  */
159
160
  appendId?: string;
160
161
  }
@@ -192,7 +193,7 @@ export declare function isCommitTransient(err: unknown): boolean;
192
193
  * ({@link isCommitTransient}: 429 rate-limits and R2 5xx blips), using
193
194
  * full-jitter exponential back-off, plus a landed-check idempotency guard so a
194
195
  * failure whose commit actually landed is never re-applied (see
195
- * `deriveAppendId`/`appendAlreadyLanded`).
196
+ * `deriveAppendIdentity`/`checkAppendLanded`).
196
197
  *
197
198
  * Retrying is safe because `icebergAppend` writes data + manifest files BEFORE
198
199
  * the atomic catalog pointer swap, so a 5xx during the upload phase aborts
package/dist/catalog.mjs CHANGED
@@ -3,6 +3,7 @@ import { restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables,
3
3
  import { cachingResolver } from "./_virtual/node_modules/.pnpm/icebird@0.8.30_patch_hash=a400e343a199b1ca18601f96348011745ac6518fc07ffa71d5adfdd2ccc7d14f/node_modules/icebird/src/fetch.mjs";
4
4
  import { icebergManifests } from "./_virtual/node_modules/.pnpm/icebird@0.8.30_patch_hash=a400e343a199b1ca18601f96348011745ac6518fc07ffa71d5adfdd2ccc7d14f/node_modules/icebird/src/manifest.mjs";
5
5
  import { s3SignedResolver } from "./_virtual/node_modules/.pnpm/icebird@0.8.30_patch_hash=a400e343a199b1ca18601f96348011745ac6518fc07ffa71d5adfdd2ccc7d14f/node_modules/icebird/src/s3.mjs";
6
+ import { deriveAppendIdentity } from "./append-identity.mjs";
6
7
  import { cacheGet, cachePut, reportCatalogCacheError } from "./catalog-cache.mjs";
7
8
  import { toIcebergDayCount } from "./date.mjs";
8
9
  import { MULTI_MONTH_MANIFEST, buildManifestPartitionFilter, manifestMonthBucket } from "./partition-prune.mjs";
@@ -228,16 +229,20 @@ async function icebergAppendRetrying(args, options = {}) {
228
229
  const maxDelayMs = options.maxDelayMs ?? 2e4;
229
230
  const sleep = options.sleep ?? defaultCommitSleep;
230
231
  const random = options.random ?? Math.random;
231
- const appendId = options.appendId ?? await deriveAppendId(args);
232
+ const identity = options.appendId === void 0 ? await deriveAppendIdentity(args.records ?? []) : {
233
+ _tag: "Explicit",
234
+ appendId: options.appendId
235
+ };
236
+ const appendId = identity.appendId;
232
237
  const stampedArgs = {
233
238
  ...args,
234
239
  snapshotProperties: {
235
240
  ...args.snapshotProperties,
236
241
  [APPEND_ID_SUMMARY_KEY]: appendId
237
242
  },
238
- isAlreadyApplied: (metadata) => hasAppendId(metadata, appendId)
243
+ isAlreadyApplied: (metadata) => hasAppendIdentity(metadata, identity)
239
244
  };
240
- let check = await checkAppendLanded(args, appendId);
245
+ let check = await checkAppendLanded(args, identity);
241
246
  if (check.landed) return;
242
247
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
243
248
  const err = await icebergAppend({
@@ -246,7 +251,7 @@ async function icebergAppendRetrying(args, options = {}) {
246
251
  }).then(() => void 0, (e) => e);
247
252
  if (err === void 0) return;
248
253
  if (!isCommitTransient(err)) throw err;
249
- check = await checkAppendLanded(args, appendId);
254
+ check = await checkAppendLanded(args, identity);
250
255
  if (check.landed) return;
251
256
  if (attempt === maxAttempts - 1) throw err;
252
257
  const ceiling = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
@@ -270,7 +275,10 @@ async function icebergAppendBatchesRetrying(args, options) {
270
275
  },
271
276
  isAlreadyApplied: (metadata) => hasAppendId(metadata, appendId)
272
277
  };
273
- let check = await checkAppendLanded(args, appendId);
278
+ let check = await checkAppendLanded(args, {
279
+ _tag: "Explicit",
280
+ appendId
281
+ });
274
282
  if (check.landed) return false;
275
283
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
276
284
  const err = await icebergAppendBatches({
@@ -280,7 +288,10 @@ async function icebergAppendBatchesRetrying(args, options) {
280
288
  }).then(() => void 0, (e) => e);
281
289
  if (err === void 0) return true;
282
290
  if (!isCommitTransient(err)) throw err;
283
- check = await checkAppendLanded(args, appendId);
291
+ check = await checkAppendLanded(args, {
292
+ _tag: "Explicit",
293
+ appendId
294
+ });
284
295
  if (check.landed) return true;
285
296
  if (attempt === maxAttempts - 1) throw err;
286
297
  const ceiling = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
@@ -288,15 +299,7 @@ async function icebergAppendBatchesRetrying(args, options) {
288
299
  }
289
300
  return false;
290
301
  }
291
- async function deriveAppendId(args) {
292
- const records = args.records ?? [];
293
- if (records.length === 0) return globalThis.crypto.randomUUID();
294
- const rowSig = (r) => Object.keys(r).sort().map((k) => `${k}=${String(r[k])}`).join("");
295
- const body = records.map(rowSig).sort().join("");
296
- const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(body));
297
- return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
298
- }
299
- async function checkAppendLanded(args, appendId) {
302
+ async function checkAppendLanded(args, identity) {
300
303
  const a = args;
301
304
  if (a.catalog?.type !== "rest" || a.namespace == null || a.table == null) return {
302
305
  landed: false,
@@ -306,7 +309,7 @@ async function checkAppendLanded(args, appendId) {
306
309
  namespace: a.namespace,
307
310
  table: a.table
308
311
  });
309
- return hasAppendId(metadata, appendId) ? { landed: true } : {
312
+ return hasAppendIdentity(metadata, identity) ? { landed: true } : {
310
313
  landed: false,
311
314
  metadata
312
315
  };
@@ -314,6 +317,14 @@ async function checkAppendLanded(args, appendId) {
314
317
  function hasAppendId(metadata, appendId) {
315
318
  return metadata.snapshots?.some((snapshot) => snapshot.summary?.[APPEND_ID_SUMMARY_KEY] === appendId) ?? false;
316
319
  }
320
+ function hasAppendIdentity(metadata, identity) {
321
+ if (hasAppendId(metadata, identity.appendId)) return true;
322
+ if (identity._tag === "Content" && hasAppendId(metadata, identity.legacyAppendId)) throw Object.assign(/* @__PURE__ */ new Error("A legacy append ID matched. Verify committed rows before retrying with an explicit append ID."), {
323
+ _tag: "LegacyAppendIdentityUnverifiable",
324
+ legacyAppendId: identity.legacyAppendId
325
+ });
326
+ return false;
327
+ }
317
328
  const SNAPSHOT_REF_TTL_MS = 18e5;
318
329
  const RESOLVED_FILES_TTL_MS = 864e5;
319
330
  const METADATA_TTL_MS = 864e5;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/lakehouse",
3
3
  "type": "module",
4
- "version": "5.9.0",
4
+ "version": "6.0.0",
5
5
  "description": "Dataset-agnostic Iceberg lakehouse layer + dataset registry for R2 Data Catalog producers (ADR-0021).",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",