@gscdump/lakehouse 7.10.0 → 7.11.1
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 +29 -0
- package/dist/_virtual/node_modules/.pnpm/icebird@0.8.30_patch_hash=3323fb6fb33622328d6d10f450feec7039468e8cad2e8667b7d8e59d48ae701b/node_modules/icebird/src/catalog/rest.mjs +20 -1
- package/dist/catalog.d.mts +4 -2
- package/dist/catalog.mjs +2 -2
- package/dist/maintenance.d.mts +2 -1
- package/dist/maintenance.mjs +2 -1
- package/dist/table-restore.d.mts +33 -0
- package/dist/table-restore.mjs +140 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -197,6 +197,35 @@ An uncertain response may follow a successful commit, so deleting staged files c
|
|
|
197
197
|
The operation never expires historical snapshots or deletes historical manifests.
|
|
198
198
|
Keep those objects while authenticated receipts or append retries still need them.
|
|
199
199
|
|
|
200
|
+
## Table restoration
|
|
201
|
+
|
|
202
|
+
Use `restoreIcebergTable(connection, table, options)` from `@gscdump/lakehouse/maintenance` for an empty Catalog target.
|
|
203
|
+
Restore every referenced metadata, manifest, and data object before registering the table.
|
|
204
|
+
Preserve their S3 locations, table UUID, snapshot IDs, and immutable bytes.
|
|
205
|
+
The caller owns the target and excludes concurrent writers through settlement.
|
|
206
|
+
|
|
207
|
+
Supply the saved metadata location, SHA-256, table UUID, head snapshot ID, and complete snapshot ID inventory.
|
|
208
|
+
The operation checks the target, saved metadata bytes, and identity before registration.
|
|
209
|
+
Metadata can use plain JSON or gzip. The byte limit applies to stored and expanded bytes.
|
|
210
|
+
The SHA-256 always authenticates the stored object bytes before decompression.
|
|
211
|
+
It registers with `overwrite: false` and verifies the returned identity.
|
|
212
|
+
Existing tables produce `target-exists`, including tables with matching saved identities.
|
|
213
|
+
Malformed bytes or mismatched identities produce `backup-invalid` before registration.
|
|
214
|
+
|
|
215
|
+
Supply `confirmAuthority` to recheck ownership before access, immediately before registration, and after acknowledgement.
|
|
216
|
+
This callback does not create an atomic transaction with external state.
|
|
217
|
+
`Restored` confirms the acknowledged table identity. It does not verify every referenced data object.
|
|
218
|
+
After restoration, run the application's receipt, object hash, and query checks through fresh read sessions.
|
|
219
|
+
Supply `cache` to invalidate its saved snapshot pointer after success.
|
|
220
|
+
|
|
221
|
+
`Unknown` means registration may have committed.
|
|
222
|
+
Keep the restored objects and inspect fresh Catalog metadata before deciding the next action.
|
|
223
|
+
The operation never retries, overwrites, or removes objects after an uncertain response.
|
|
224
|
+
Other infrastructure failures propagate.
|
|
225
|
+
|
|
226
|
+
To detach a registration while retaining its objects, use `dropIcebergTables(connection, tables, { purge: false })`.
|
|
227
|
+
The default still requests purging. Use explicit table names for a restore drill.
|
|
228
|
+
|
|
200
229
|
## License
|
|
201
230
|
|
|
202
231
|
[MIT](../../LICENSE)
|
|
@@ -64,6 +64,25 @@ async function restCatalogCreateTable(ctx, { namespace, table, schema, location,
|
|
|
64
64
|
config: responseBody.config ?? {}
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
|
+
async function restCatalogRegisterTable(ctx, { namespace, table, metadataLocation, overwrite }) {
|
|
68
|
+
const ns = encodeNamespace(namespace);
|
|
69
|
+
const body = {
|
|
70
|
+
name: table,
|
|
71
|
+
"metadata-location": metadataLocation
|
|
72
|
+
};
|
|
73
|
+
if (overwrite !== void 0) body.overwrite = overwrite;
|
|
74
|
+
const res = await restFetch(ctx, `namespaces/${ns}/register`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "content-type": "application/json" },
|
|
77
|
+
body: stringifyIcebergJson(body)
|
|
78
|
+
});
|
|
79
|
+
const responseBody = parseIcebergJson(await res.text());
|
|
80
|
+
return {
|
|
81
|
+
metadataLocation: responseBody["metadata-location"],
|
|
82
|
+
metadata: responseBody.metadata,
|
|
83
|
+
config: responseBody.config ?? {}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
67
86
|
async function restCatalogUpdateTable(ctx, { namespace, table, requirements, updates }) {
|
|
68
87
|
const res = await restFetch(ctx, `namespaces/${encodeNamespace(namespace)}/tables/${encodeURIComponent(table)}`, {
|
|
69
88
|
method: "POST",
|
|
@@ -161,4 +180,4 @@ async function paginate(baseParams, fetchPage) {
|
|
|
161
180
|
pageToken = encodeURIComponent(nextPageToken);
|
|
162
181
|
}
|
|
163
182
|
}
|
|
164
|
-
export { restCatalogConnect, restCatalogCreateNamespace, restCatalogCreateTable, restCatalogDropTable, restCatalogListTables, restCatalogLoadTable, restCatalogUpdateTable };
|
|
183
|
+
export { restCatalogConnect, restCatalogCreateNamespace, restCatalogCreateTable, restCatalogDropTable, restCatalogListTables, restCatalogLoadTable, restCatalogRegisterTable, restCatalogUpdateTable };
|
package/dist/catalog.d.mts
CHANGED
|
@@ -135,10 +135,12 @@ export interface IcebergTableOpResult {
|
|
|
135
135
|
error?: string;
|
|
136
136
|
}
|
|
137
137
|
/**
|
|
138
|
-
* Drop tables from the catalog namespace
|
|
138
|
+
* Drop tables from the catalog namespace. Set purge to false to preserve their objects.
|
|
139
139
|
* Defaults to every table currently in the namespace.
|
|
140
140
|
*/
|
|
141
|
-
export declare function dropIcebergTables(conn: IcebergConnection, tables?: readonly string[]
|
|
141
|
+
export declare function dropIcebergTables(conn: IcebergConnection, tables?: readonly string[], options?: {
|
|
142
|
+
purge: boolean;
|
|
143
|
+
}): Promise<IcebergTableOpResult[]>;
|
|
142
144
|
/** Tunable retry policy for {@link icebergAppendRetrying}. */
|
|
143
145
|
export interface CommitRetryOptions {
|
|
144
146
|
/** Total attempts, including the first. Default 6. */
|
package/dist/catalog.mjs
CHANGED
|
@@ -190,7 +190,7 @@ async function ensureIcebergNamespace(conn) {
|
|
|
190
190
|
async function listIcebergTables(conn) {
|
|
191
191
|
return (await restCatalogListTables(conn.catalog, { namespace: conn.namespace })).map((t) => t.name).sort();
|
|
192
192
|
}
|
|
193
|
-
async function dropIcebergTables(conn, tables) {
|
|
193
|
+
async function dropIcebergTables(conn, tables, options = { purge: true }) {
|
|
194
194
|
const targets = tables ?? (await restCatalogListTables(conn.catalog, { namespace: conn.namespace })).map((t) => t.name);
|
|
195
195
|
const results = [];
|
|
196
196
|
const { icebergDropTable } = await useIcebirdWriteModule();
|
|
@@ -198,7 +198,7 @@ async function dropIcebergTables(conn, tables) {
|
|
|
198
198
|
catalog: conn.catalog,
|
|
199
199
|
namespace: conn.namespace,
|
|
200
200
|
table,
|
|
201
|
-
purgeRequested:
|
|
201
|
+
purgeRequested: options.purge
|
|
202
202
|
}).then(() => results.push({
|
|
203
203
|
table,
|
|
204
204
|
ok: true
|
package/dist/maintenance.d.mts
CHANGED
|
@@ -2,4 +2,5 @@ import { isCommitRateLimited, isCommitServerError, isCommitTransient } from "./c
|
|
|
2
2
|
import { MergeIcebergManifestsOptions, MergeIcebergManifestsResult, mergeIcebergManifests } from "./manifest-merge.mjs";
|
|
3
3
|
import { SweepListPage, SweepListedObject, SweepStorageClient, SweepUncommittedOrphansOptions, SweepUncommittedOrphansResult, sweepUncommittedOrphans } from "./orphan-sweep.mjs";
|
|
4
4
|
import { ExpireIcebergSnapshotsOptions, ExpireIcebergSnapshotsResult, PlanIcebergSnapshotRetentionOptions, PlanIcebergSnapshotRetentionResult, expireIcebergSnapshots, planIcebergSnapshotRetention } from "./snapshot-retention.mjs";
|
|
5
|
-
|
|
5
|
+
import { RestoreIcebergTableOptions, RestoreIcebergTableResult, restoreIcebergTable } from "./table-restore.mjs";
|
|
6
|
+
export { type ExpireIcebergSnapshotsOptions, type ExpireIcebergSnapshotsResult, type MergeIcebergManifestsOptions, type MergeIcebergManifestsResult, type PlanIcebergSnapshotRetentionOptions, type PlanIcebergSnapshotRetentionResult, type RestoreIcebergTableOptions, type RestoreIcebergTableResult, type SweepListPage, type SweepListedObject, type SweepStorageClient, type SweepUncommittedOrphansOptions, type SweepUncommittedOrphansResult, expireIcebergSnapshots, isCommitRateLimited, isCommitServerError, isCommitTransient, mergeIcebergManifests, planIcebergSnapshotRetention, restoreIcebergTable, sweepUncommittedOrphans };
|
package/dist/maintenance.mjs
CHANGED
|
@@ -2,4 +2,5 @@ import { isCommitRateLimited, isCommitServerError, isCommitTransient } from "./c
|
|
|
2
2
|
import { mergeIcebergManifests } from "./manifest-merge.mjs";
|
|
3
3
|
import { sweepUncommittedOrphans } from "./orphan-sweep.mjs";
|
|
4
4
|
import { expireIcebergSnapshots, planIcebergSnapshotRetention } from "./snapshot-retention.mjs";
|
|
5
|
-
|
|
5
|
+
import { restoreIcebergTable } from "./table-restore.mjs";
|
|
6
|
+
export { expireIcebergSnapshots, isCommitRateLimited, isCommitServerError, isCommitTransient, mergeIcebergManifests, planIcebergSnapshotRetention, restoreIcebergTable, sweepUncommittedOrphans };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { CatalogCache } from "./catalog-cache.mjs";
|
|
2
|
+
import { IcebergConnection } from "./catalog.mjs";
|
|
3
|
+
export interface RestoreIcebergTableOptions {
|
|
4
|
+
/** Immutable metadata object restored before its Catalog registration. */
|
|
5
|
+
metadataLocation: string;
|
|
6
|
+
metadataSha256: string;
|
|
7
|
+
tableUuid: string;
|
|
8
|
+
headSnapshotId: string | null;
|
|
9
|
+
/** Complete snapshot identity inventory from the saved metadata. */
|
|
10
|
+
snapshotIds: readonly string[];
|
|
11
|
+
/** The caller excludes writers and owns the target through settlement. */
|
|
12
|
+
confirmAuthority: () => Promise<boolean>;
|
|
13
|
+
/** Bounds stored and expanded metadata. Defaults to 16 MiB. The maximum is 64 MiB. */
|
|
14
|
+
maxMetadataBytes?: number;
|
|
15
|
+
cache?: CatalogCache;
|
|
16
|
+
}
|
|
17
|
+
export type RestoreIcebergTableResult = {
|
|
18
|
+
_tag: 'Restored';
|
|
19
|
+
tableUuid: string;
|
|
20
|
+
snapshotIds: string[];
|
|
21
|
+
} | {
|
|
22
|
+
_tag: 'Err';
|
|
23
|
+
reason: 'invalid-options' | 'target-exists' | 'backup-invalid' | 'authority-changed';
|
|
24
|
+
} | {
|
|
25
|
+
_tag: 'Unknown';
|
|
26
|
+
reason: 'request-failed';
|
|
27
|
+
cause: unknown;
|
|
28
|
+
} | {
|
|
29
|
+
_tag: 'Unknown';
|
|
30
|
+
reason: 'acknowledgement-invalid' | 'authority-changed';
|
|
31
|
+
};
|
|
32
|
+
/** Restore a verified metadata pointer into an empty target. Never replace an existing table. */
|
|
33
|
+
export declare function restoreIcebergTable(conn: IcebergConnection, table: string, options: RestoreIcebergTableOptions): Promise<RestoreIcebergTableResult>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { parseIcebergJson } from "./_virtual/node_modules/.pnpm/icebird@0.8.30_patch_hash=3323fb6fb33622328d6d10f450feec7039468e8cad2e8667b7d8e59d48ae701b/node_modules/icebird/src/json.mjs";
|
|
2
|
+
import { restCatalogLoadTable, restCatalogRegisterTable } from "./_virtual/node_modules/.pnpm/icebird@0.8.30_patch_hash=3323fb6fb33622328d6d10f450feec7039468e8cad2e8667b7d8e59d48ae701b/node_modules/icebird/src/catalog/rest.mjs";
|
|
3
|
+
import { invalidateSnapshotRef } from "./catalog.mjs";
|
|
4
|
+
function validOptions(table, options) {
|
|
5
|
+
const maxBytes = options.maxMetadataBytes ?? 16777216;
|
|
6
|
+
return table.length > 0 && table.length <= 255 && /^s3:\/\/[a-z0-9][a-z0-9.-]+\/[^\s?#]+$/.test(options.metadataLocation) && /^[a-f0-9]{64}$/.test(options.metadataSha256) && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(options.tableUuid) && Number.isSafeInteger(maxBytes) && maxBytes > 0 && maxBytes <= 67108864 && options.snapshotIds.length <= 1e5 && options.snapshotIds.every((id) => /^(?:0|[1-9]\d{0,18})$/.test(id)) && new Set(options.snapshotIds).size === options.snapshotIds.length && (options.headSnapshotId === null ? options.snapshotIds.length === 0 : options.snapshotIds.includes(options.headSnapshotId));
|
|
7
|
+
}
|
|
8
|
+
function matchesBackup(loaded, options) {
|
|
9
|
+
if (!loaded.metadata || typeof loaded.metadata !== "object" || Array.isArray(loaded.metadata)) return false;
|
|
10
|
+
const metadata = loaded.metadata;
|
|
11
|
+
const snapshots = metadata.snapshots ?? [];
|
|
12
|
+
if (!Array.isArray(snapshots) || snapshots.some((snapshot) => !snapshot || typeof snapshot !== "object")) return false;
|
|
13
|
+
const ids = snapshots.map((snapshot) => String(snapshot["snapshot-id"]));
|
|
14
|
+
const expected = new Set(options.snapshotIds);
|
|
15
|
+
const rawHead = metadata["current-snapshot-id"];
|
|
16
|
+
const head = rawHead == null || String(rawHead) === "-1" ? null : String(rawHead);
|
|
17
|
+
return loaded.metadataLocation === options.metadataLocation && metadata["table-uuid"] === options.tableUuid && head === options.headSnapshotId && ids.length === expected.size && new Set(ids).size === expected.size && ids.every((id) => expected.has(id));
|
|
18
|
+
}
|
|
19
|
+
async function expandMetadata(bytes, maxBytes) {
|
|
20
|
+
if (bytes[0] !== 31 || bytes[1] !== 139) return bytes;
|
|
21
|
+
const reader = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")).getReader();
|
|
22
|
+
try {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
let total = 0;
|
|
25
|
+
for (;;) {
|
|
26
|
+
const chunk = await reader.read();
|
|
27
|
+
if (chunk.done) break;
|
|
28
|
+
total += chunk.value.byteLength;
|
|
29
|
+
if (total > maxBytes) {
|
|
30
|
+
await reader.cancel();
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
chunks.push(chunk.value);
|
|
34
|
+
}
|
|
35
|
+
const expanded = new Uint8Array(total);
|
|
36
|
+
let offset = 0;
|
|
37
|
+
for (const chunk of chunks) {
|
|
38
|
+
expanded.set(chunk, offset);
|
|
39
|
+
offset += chunk.byteLength;
|
|
40
|
+
}
|
|
41
|
+
return expanded;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
} finally {
|
|
45
|
+
reader.releaseLock();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function restoreIcebergTable(conn, table, options) {
|
|
49
|
+
if (!validOptions(table, options)) return {
|
|
50
|
+
_tag: "Err",
|
|
51
|
+
reason: "invalid-options"
|
|
52
|
+
};
|
|
53
|
+
if (!await options.confirmAuthority()) return {
|
|
54
|
+
_tag: "Err",
|
|
55
|
+
reason: "authority-changed"
|
|
56
|
+
};
|
|
57
|
+
if (await restCatalogLoadTable(conn.catalog, {
|
|
58
|
+
namespace: conn.namespace,
|
|
59
|
+
table
|
|
60
|
+
}).then(() => true, (error) => {
|
|
61
|
+
if (error && typeof error === "object" && "status" in error && error.status === 404) return false;
|
|
62
|
+
throw error;
|
|
63
|
+
})) return {
|
|
64
|
+
_tag: "Err",
|
|
65
|
+
reason: "target-exists"
|
|
66
|
+
};
|
|
67
|
+
const reader = await conn.resolver.reader(options.metadataLocation);
|
|
68
|
+
const maxBytes = options.maxMetadataBytes ?? 16777216;
|
|
69
|
+
if (!Number.isSafeInteger(reader.byteLength) || reader.byteLength <= 0 || reader.byteLength > maxBytes) return {
|
|
70
|
+
_tag: "Err",
|
|
71
|
+
reason: "backup-invalid"
|
|
72
|
+
};
|
|
73
|
+
const bytes = new Uint8Array(await reader.slice(0, reader.byteLength));
|
|
74
|
+
if (bytes.byteLength !== reader.byteLength) return {
|
|
75
|
+
_tag: "Err",
|
|
76
|
+
reason: "backup-invalid"
|
|
77
|
+
};
|
|
78
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
79
|
+
if ([...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("") !== options.metadataSha256) return {
|
|
80
|
+
_tag: "Err",
|
|
81
|
+
reason: "backup-invalid"
|
|
82
|
+
};
|
|
83
|
+
const expanded = await expandMetadata(bytes, maxBytes);
|
|
84
|
+
if (!expanded) return {
|
|
85
|
+
_tag: "Err",
|
|
86
|
+
reason: "backup-invalid"
|
|
87
|
+
};
|
|
88
|
+
let metadata;
|
|
89
|
+
try {
|
|
90
|
+
metadata = parseIcebergJson(new TextDecoder("utf-8", { fatal: true }).decode(expanded));
|
|
91
|
+
} catch {
|
|
92
|
+
return {
|
|
93
|
+
_tag: "Err",
|
|
94
|
+
reason: "backup-invalid"
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (!matchesBackup({
|
|
98
|
+
metadataLocation: options.metadataLocation,
|
|
99
|
+
metadata
|
|
100
|
+
}, options)) return {
|
|
101
|
+
_tag: "Err",
|
|
102
|
+
reason: "backup-invalid"
|
|
103
|
+
};
|
|
104
|
+
if (!await options.confirmAuthority()) return {
|
|
105
|
+
_tag: "Err",
|
|
106
|
+
reason: "authority-changed"
|
|
107
|
+
};
|
|
108
|
+
const registered = await restCatalogRegisterTable(conn.catalog, {
|
|
109
|
+
namespace: conn.namespace,
|
|
110
|
+
table,
|
|
111
|
+
metadataLocation: options.metadataLocation,
|
|
112
|
+
overwrite: false
|
|
113
|
+
}).then((value) => ({
|
|
114
|
+
_tag: "Acknowledged",
|
|
115
|
+
value
|
|
116
|
+
}), (cause) => ({
|
|
117
|
+
_tag: "Unknown",
|
|
118
|
+
reason: "request-failed",
|
|
119
|
+
cause
|
|
120
|
+
}));
|
|
121
|
+
if (registered._tag === "Unknown") return registered;
|
|
122
|
+
if (!matchesBackup({
|
|
123
|
+
metadataLocation: registered.value.metadataLocation,
|
|
124
|
+
metadata: registered.value.metadata
|
|
125
|
+
}, options)) return {
|
|
126
|
+
_tag: "Unknown",
|
|
127
|
+
reason: "acknowledgement-invalid"
|
|
128
|
+
};
|
|
129
|
+
if (!await options.confirmAuthority()) return {
|
|
130
|
+
_tag: "Unknown",
|
|
131
|
+
reason: "authority-changed"
|
|
132
|
+
};
|
|
133
|
+
if (options.cache) await invalidateSnapshotRef(options.cache, conn.namespace, table, conn.cacheScope);
|
|
134
|
+
return {
|
|
135
|
+
_tag: "Restored",
|
|
136
|
+
tableUuid: options.tableUuid,
|
|
137
|
+
snapshotIds: [...options.snapshotIds]
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export { restoreIcebergTable };
|
package/package.json
CHANGED