@gscdump/lakehouse 5.9.1 → 7.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 +21 -0
- package/dist/append-identity.mjs +42 -0
- package/dist/catalog.d.mts +4 -3
- package/dist/catalog.mjs +27 -16
- package/package.json +1 -1
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 };
|
package/dist/catalog.d.mts
CHANGED
|
@@ -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
|
-
*
|
|
157
|
-
*
|
|
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
|
-
* `
|
|
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
|
|
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) =>
|
|
243
|
+
isAlreadyApplied: (metadata) => hasAppendIdentity(metadata, identity)
|
|
239
244
|
};
|
|
240
|
-
let check = await checkAppendLanded(args,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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