@gscdump/lakehouse 7.9.1 → 7.10.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.
@@ -1,4 +1,5 @@
1
1
  import { isCommitRateLimited, isCommitServerError, isCommitTransient } from "./catalog.mjs";
2
2
  import { MergeIcebergManifestsOptions, MergeIcebergManifestsResult, mergeIcebergManifests } from "./manifest-merge.mjs";
3
3
  import { SweepListPage, SweepListedObject, SweepStorageClient, SweepUncommittedOrphansOptions, SweepUncommittedOrphansResult, sweepUncommittedOrphans } from "./orphan-sweep.mjs";
4
- export { type MergeIcebergManifestsOptions, type MergeIcebergManifestsResult, type SweepListPage, type SweepListedObject, type SweepStorageClient, type SweepUncommittedOrphansOptions, type SweepUncommittedOrphansResult, isCommitRateLimited, isCommitServerError, isCommitTransient, mergeIcebergManifests, sweepUncommittedOrphans };
4
+ import { ExpireIcebergSnapshotsOptions, ExpireIcebergSnapshotsResult, PlanIcebergSnapshotRetentionOptions, PlanIcebergSnapshotRetentionResult, expireIcebergSnapshots, planIcebergSnapshotRetention } from "./snapshot-retention.mjs";
5
+ export { type ExpireIcebergSnapshotsOptions, type ExpireIcebergSnapshotsResult, type MergeIcebergManifestsOptions, type MergeIcebergManifestsResult, type PlanIcebergSnapshotRetentionOptions, type PlanIcebergSnapshotRetentionResult, type SweepListPage, type SweepListedObject, type SweepStorageClient, type SweepUncommittedOrphansOptions, type SweepUncommittedOrphansResult, expireIcebergSnapshots, isCommitRateLimited, isCommitServerError, isCommitTransient, mergeIcebergManifests, planIcebergSnapshotRetention, sweepUncommittedOrphans };
@@ -1,4 +1,5 @@
1
1
  import { isCommitRateLimited, isCommitServerError, isCommitTransient } from "./catalog.mjs";
2
2
  import { mergeIcebergManifests } from "./manifest-merge.mjs";
3
3
  import { sweepUncommittedOrphans } from "./orphan-sweep.mjs";
4
- export { isCommitRateLimited, isCommitServerError, isCommitTransient, mergeIcebergManifests, sweepUncommittedOrphans };
4
+ import { expireIcebergSnapshots, planIcebergSnapshotRetention } from "./snapshot-retention.mjs";
5
+ export { expireIcebergSnapshots, isCommitRateLimited, isCommitServerError, isCommitTransient, mergeIcebergManifests, planIcebergSnapshotRetention, sweepUncommittedOrphans };
@@ -0,0 +1,48 @@
1
+ import { CatalogCache } from "./catalog-cache.mjs";
2
+ import { IcebergConnection } from "./catalog.mjs";
3
+ export interface PlanIcebergSnapshotRetentionOptions {
4
+ /** Snapshots at or after this timestamp remain available. */
5
+ olderThanMs: number;
6
+ /** Keep at least this many newest snapshots, independently of age. */
7
+ retainLast: number;
8
+ /** The caller owns in-flight writes, recovery, and rollback retention. */
9
+ protectedSnapshotIds: readonly string[];
10
+ /** Bound each metadata mutation. The default is one thousand removals. */
11
+ maxExpired?: number;
12
+ }
13
+ export interface ExpireIcebergSnapshotsOptions extends PlanIcebergSnapshotRetentionOptions {
14
+ /** Recheck caller authority. The caller excludes branch/tag changes through commit settlement. */
15
+ confirmAuthority: () => Promise<boolean>;
16
+ cache?: CatalogCache;
17
+ }
18
+ export type PlanIcebergSnapshotRetentionResult = {
19
+ _tag: 'Planned';
20
+ tableUuid: string;
21
+ headSnapshotId: string;
22
+ snapshotIds: string[];
23
+ snapshotCount: number;
24
+ retained: number;
25
+ eligibleSnapshotCount: number;
26
+ } | {
27
+ _tag: 'Empty';
28
+ tableUuid: string;
29
+ } | {
30
+ _tag: 'Err';
31
+ reason: 'invalid-options' | 'invalid-inventory' | 'unsupported-refs';
32
+ };
33
+ export type ExpireIcebergSnapshotsResult = {
34
+ _tag: 'Expired';
35
+ snapshotIds: string[];
36
+ retained: number;
37
+ } | {
38
+ _tag: 'Unchanged';
39
+ } | {
40
+ _tag: 'Conflict';
41
+ } | {
42
+ _tag: 'Err';
43
+ reason: 'invalid-options' | 'invalid-inventory' | 'unsupported-refs' | 'authority-changed';
44
+ };
45
+ /** Inspect a fresh Catalog inventory without writing or granting future expiry authority. */
46
+ export declare function planIcebergSnapshotRetention(conn: Pick<IcebergConnection, 'catalog' | 'namespace'>, table: string, options: PlanIcebergSnapshotRetentionOptions): Promise<PlanIcebergSnapshotRetentionResult>;
47
+ /** Remove old snapshot metadata. No object is deleted. Caller retention authority remains mandatory. */
48
+ export declare function expireIcebergSnapshots(conn: IcebergConnection, table: string, options: ExpireIcebergSnapshotsOptions): Promise<ExpireIcebergSnapshotsResult>;
@@ -0,0 +1,116 @@
1
+ import { restCatalogLoadTable, restCatalogUpdateTable } from "./_virtual/node_modules/.pnpm/icebird@0.8.30_patch_hash=3323fb6fb33622328d6d10f450feec7039468e8cad2e8667b7d8e59d48ae701b/node_modules/icebird/src/catalog/rest.mjs";
2
+ import { invalidateSnapshotRef } from "./catalog.mjs";
3
+ function validOptions(options) {
4
+ const maxExpired = options.maxExpired ?? 1e3;
5
+ return Number.isSafeInteger(options.olderThanMs) && options.olderThanMs >= 0 && Number.isSafeInteger(options.retainLast) && options.retainLast >= 1 && Number.isSafeInteger(maxExpired) && maxExpired >= 1 && maxExpired <= 1e3;
6
+ }
7
+ function planRetention(metadata, options) {
8
+ const snapshots = metadata.snapshots ?? [];
9
+ const currentId = metadata["current-snapshot-id"];
10
+ const invalid = {
11
+ _tag: "Err",
12
+ reason: "invalid-inventory"
13
+ };
14
+ if (!metadata["table-uuid"]) return invalid;
15
+ const refs = metadata.refs ?? {};
16
+ if (Object.keys(refs).some((name) => name !== "main")) return {
17
+ _tag: "Err",
18
+ reason: "unsupported-refs"
19
+ };
20
+ if (snapshots.length === 0) return (currentId == null || String(currentId) === "-1") && options.protectedSnapshotIds.length === 0 && !refs.main ? {
21
+ _tag: "Empty",
22
+ tableUuid: metadata["table-uuid"]
23
+ } : invalid;
24
+ if (currentId === void 0 || currentId === null) return invalid;
25
+ const inventory = /* @__PURE__ */ new Map();
26
+ for (const snapshot of snapshots) {
27
+ const id = String(snapshot["snapshot-id"]);
28
+ if (!/^\d+$/.test(id) || inventory.has(id) || !Number.isSafeInteger(snapshot["timestamp-ms"]) || snapshot["timestamp-ms"] < 0) return invalid;
29
+ inventory.set(id, snapshot);
30
+ }
31
+ const head = String(currentId);
32
+ if (!inventory.has(head) || options.protectedSnapshotIds.some((id) => !inventory.has(id))) return invalid;
33
+ if (refs.main && (refs.main.type !== "branch" || String(refs.main["snapshot-id"]) !== head)) return invalid;
34
+ const ordered = snapshots.toSorted((a, b) => b["timestamp-ms"] - a["timestamp-ms"] || (BigInt(a["snapshot-id"]) < BigInt(b["snapshot-id"]) ? 1 : -1));
35
+ const keep = /* @__PURE__ */ new Set([
36
+ head,
37
+ ...options.protectedSnapshotIds,
38
+ ...ordered.slice(0, options.retainLast).map((snapshot) => String(snapshot["snapshot-id"]))
39
+ ]);
40
+ const eligible = ordered.toReversed().filter((snapshot) => snapshot["timestamp-ms"] < options.olderThanMs && !keep.has(String(snapshot["snapshot-id"])));
41
+ const expired = eligible.slice(0, options.maxExpired ?? 1e3);
42
+ return {
43
+ _tag: "Planned",
44
+ tableUuid: metadata["table-uuid"],
45
+ headSnapshotId: head,
46
+ snapshotIds: expired.map((snapshot) => String(snapshot["snapshot-id"])),
47
+ snapshotCount: snapshots.length,
48
+ retained: snapshots.length - expired.length,
49
+ eligibleSnapshotCount: eligible.length
50
+ };
51
+ }
52
+ async function planIcebergSnapshotRetention(conn, table, options) {
53
+ if (!validOptions(options)) return {
54
+ _tag: "Err",
55
+ reason: "invalid-options"
56
+ };
57
+ const { metadata } = await restCatalogLoadTable(conn.catalog, {
58
+ namespace: conn.namespace,
59
+ table
60
+ });
61
+ return planRetention(metadata, options);
62
+ }
63
+ async function expireIcebergSnapshots(conn, table, options) {
64
+ if (!validOptions(options)) return {
65
+ _tag: "Err",
66
+ reason: "invalid-options"
67
+ };
68
+ const { metadata } = await restCatalogLoadTable(conn.catalog, {
69
+ namespace: conn.namespace,
70
+ table
71
+ });
72
+ const plan = planRetention(metadata, options);
73
+ if (plan._tag === "Err") return plan;
74
+ if (plan._tag === "Empty" || plan.snapshotIds.length === 0) return { _tag: "Unchanged" };
75
+ const snapshots = metadata.snapshots ?? [];
76
+ const removed = new Set(plan.snapshotIds);
77
+ const expired = snapshots.filter((snapshot) => removed.has(String(snapshot["snapshot-id"])));
78
+ if (!await options.confirmAuthority()) return {
79
+ _tag: "Err",
80
+ reason: "authority-changed"
81
+ };
82
+ const committed = await restCatalogUpdateTable(conn.catalog, {
83
+ namespace: conn.namespace,
84
+ table,
85
+ requirements: [{
86
+ type: "assert-table-uuid",
87
+ uuid: plan.tableUuid
88
+ }, {
89
+ "type": "assert-ref-snapshot-id",
90
+ "ref": "main",
91
+ "snapshot-id": metadata["current-snapshot-id"]
92
+ }],
93
+ updates: [{
94
+ "action": "remove-snapshots",
95
+ "snapshot-ids": expired.map((snapshot) => snapshot["snapshot-id"])
96
+ }]
97
+ }).then((value) => ({
98
+ _tag: "Committed",
99
+ value
100
+ }), (error) => {
101
+ if (typeof error === "object" && error !== null && "status" in error && (error.status === 409 || error.status === 412)) return { _tag: "Conflict" };
102
+ throw error;
103
+ });
104
+ if (committed._tag === "Conflict") return { _tag: "Conflict" };
105
+ const expected = new Set(snapshots.map((snapshot) => String(snapshot["snapshot-id"])).filter((id) => !removed.has(id)));
106
+ const acknowledged = committed.value.metadata;
107
+ const actual = acknowledged.snapshots?.map((snapshot) => String(snapshot["snapshot-id"])) ?? [];
108
+ if (acknowledged["table-uuid"] !== plan.tableUuid || String(acknowledged["current-snapshot-id"]) !== plan.headSnapshotId || actual.length !== expected.size || new Set(actual).size !== expected.size || actual.some((id) => !expected.has(id))) throw new Error("Snapshot expiry acknowledgement does not match the committed plan.");
109
+ if (options.cache) await invalidateSnapshotRef(options.cache, conn.namespace, table, conn.cacheScope);
110
+ return {
111
+ _tag: "Expired",
112
+ snapshotIds: plan.snapshotIds,
113
+ retained: actual.length
114
+ };
115
+ }
116
+ export { expireIcebergSnapshots, planIcebergSnapshotRetention };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/lakehouse",
3
3
  "type": "module",
4
- "version": "7.9.1",
4
+ "version": "7.10.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",