@gscdump/lakehouse 0.40.2 → 1.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 +49 -0
- package/dist/_chunks/libs/hyparquet-compressors.mjs +2 -13
- package/dist/maintenance.d.mts +94 -0
- package/dist/maintenance.mjs +140 -0
- package/dist/unsafe-raw.d.mts +2 -94
- package/dist/unsafe-raw.mjs +2 -139
- package/package.json +7 -2
- package/dist/vendor/hysnappy-purejs.d.mts +0 -29
- package/dist/vendor/hysnappy-purejs.mjs +0 -2
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @gscdump/lakehouse
|
|
2
|
+
|
|
3
|
+
Dataset-agnostic Iceberg catalog, schema, and dataset-registry primitives for
|
|
4
|
+
R2 Data Catalog producers.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @gscdump/lakehouse
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Node.js 22 or newer is required.
|
|
13
|
+
|
|
14
|
+
## Public surfaces
|
|
15
|
+
|
|
16
|
+
- `@gscdump/lakehouse` — connect to a catalog, define datasets, derive table
|
|
17
|
+
specifications, resolve data files, and use bigint-safe serialization.
|
|
18
|
+
- `@gscdump/lakehouse/maintenance` — stable commit classification and orphan
|
|
19
|
+
cleanup workflows.
|
|
20
|
+
- `@gscdump/lakehouse/provisioning` — adopt, allocate, or provision R2 Data
|
|
21
|
+
Catalog resources.
|
|
22
|
+
- `@gscdump/lakehouse/unsafe-raw` — explicit escape hatch for package adapters
|
|
23
|
+
and raw-metadata diagnostics. Application maintenance belongs on the stable
|
|
24
|
+
maintenance subpath.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { connectIcebergCatalog, listIcebergTables } from '@gscdump/lakehouse'
|
|
28
|
+
import { isCommitRateLimited } from '@gscdump/lakehouse/maintenance'
|
|
29
|
+
|
|
30
|
+
const connection = await connectIcebergCatalog(config)
|
|
31
|
+
const tables = await listIcebergTables(connection)
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// Commit through a dataset or package adapter.
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (isCommitRateLimited(error)) {
|
|
38
|
+
// Retry using the caller's bounded backoff policy.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The dataset registry is the normal authoring boundary. Raw Icebird table
|
|
44
|
+
creation and append primitives are intentionally excluded from the package
|
|
45
|
+
root so producers cannot silently bypass registered dataset definitions.
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
[MIT](../../LICENSE)
|
|
@@ -1,16 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import "./hyparquet.mjs";
|
|
2
2
|
import "./fzstd.mjs";
|
|
3
|
-
function decode(input, outputLength) {
|
|
4
|
-
const output = new Uint8Array(outputLength);
|
|
5
|
-
snappyUncompress$1(input, output);
|
|
6
|
-
return output;
|
|
7
|
-
}
|
|
8
|
-
function snappyUncompressor() {
|
|
9
|
-
return decode;
|
|
10
|
-
}
|
|
11
|
-
function snappyUncompress(input, outputLength) {
|
|
12
|
-
return decode(input, outputLength);
|
|
13
|
-
}
|
|
14
3
|
const BROTLI_READ_SIZE = 4096;
|
|
15
4
|
const BROTLI_IBUF_SIZE = 8224;
|
|
16
5
|
const BROTLI_IBUF_MASK = 2 * BROTLI_READ_SIZE - 1;
|
|
@@ -2793,4 +2782,4 @@ new Uint8Array([
|
|
|
2793
2782
|
5,
|
|
2794
2783
|
5
|
|
2795
2784
|
]);
|
|
2796
|
-
export { gunzip
|
|
2785
|
+
export { gunzip };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { IcebergConnection, isCommitRateLimited } from "./_chunks/catalog.mjs";
|
|
2
|
+
/** One listed object under a data prefix. */
|
|
3
|
+
interface SweepListedObject {
|
|
4
|
+
key: string;
|
|
5
|
+
uploaded: Date;
|
|
6
|
+
}
|
|
7
|
+
/** One page of a prefix listing. */
|
|
8
|
+
interface SweepListPage {
|
|
9
|
+
objects: readonly SweepListedObject[];
|
|
10
|
+
truncated: boolean;
|
|
11
|
+
cursor?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Minimal storage client `sweepUncommittedOrphans` needs: list-by-prefix +
|
|
15
|
+
* batch delete against the warehouse bucket. Deliberately NOT an S3 SDK
|
|
16
|
+
* client shape — this interface is structurally satisfied by Cloudflare's
|
|
17
|
+
* native `R2Bucket` Workers binding (`env.R2_ANALYTICS.list({ prefix })` /
|
|
18
|
+
* `env.R2_ANALYTICS.delete(keys)`), so a caller running inside a Worker can
|
|
19
|
+
* pass the binding straight through with no adapter. Any other
|
|
20
|
+
* S3-compatible client can be wrapped to this shape.
|
|
21
|
+
*/
|
|
22
|
+
interface SweepStorageClient {
|
|
23
|
+
list: (options: {
|
|
24
|
+
prefix: string;
|
|
25
|
+
cursor?: string;
|
|
26
|
+
}) => Promise<SweepListPage>;
|
|
27
|
+
delete: (keys: string[]) => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
interface SweepUncommittedOrphansOptions {
|
|
30
|
+
conn: IcebergConnection;
|
|
31
|
+
s3: SweepStorageClient;
|
|
32
|
+
/**
|
|
33
|
+
* Bare R2 bucket name backing `conn`'s warehouse. Every scanned table's
|
|
34
|
+
* `location` must resolve into this exact bucket; a table whose location
|
|
35
|
+
* points elsewhere is SKIPPED — never guessed at, never touched.
|
|
36
|
+
*/
|
|
37
|
+
bucket: string;
|
|
38
|
+
/**
|
|
39
|
+
* Restrict the sweep to these tables; defaults to every table currently
|
|
40
|
+
* in `conn.namespace` (`conn` already carries the namespace scope, so
|
|
41
|
+
* there's no separate `namespace` field here).
|
|
42
|
+
*/
|
|
43
|
+
tables?: readonly string[];
|
|
44
|
+
/** Only delete objects older than this many hours. Default 48. */
|
|
45
|
+
graceHours?: number;
|
|
46
|
+
/** Hard cap on deletions in one run. Default 500. */
|
|
47
|
+
maxDeletes?: number;
|
|
48
|
+
/** Compute the delete set but never call `s3.delete`. Default false. */
|
|
49
|
+
dryRun?: boolean;
|
|
50
|
+
/** Injectable clock (ms epoch). Defaults to `Date.now`. */
|
|
51
|
+
now?: () => number;
|
|
52
|
+
/** Maximum catalog/object-store reads held in flight. Default 4, max 32. */
|
|
53
|
+
ioConcurrency?: number;
|
|
54
|
+
}
|
|
55
|
+
interface SweepUncommittedOrphansResult {
|
|
56
|
+
/** Tables whose retained snapshots were successfully walked. */
|
|
57
|
+
scannedTables: string[];
|
|
58
|
+
/** Tables skipped because their `location` didn't resolve into `bucket` — never scanned, never touched. */
|
|
59
|
+
skippedTables: string[];
|
|
60
|
+
/** Count of live (referenced-by-some-retained-snapshot) files found across `scannedTables`. */
|
|
61
|
+
liveFileCount: number;
|
|
62
|
+
/** Objects under a scanned table's data prefix with no snapshot reference anywhere, past `graceHours`. */
|
|
63
|
+
candidateCount: number;
|
|
64
|
+
/** Keys deleted (or, under `dryRun`, that WOULD be deleted). */
|
|
65
|
+
deleted: string[];
|
|
66
|
+
dryRun: boolean;
|
|
67
|
+
/** True when `candidateCount` exceeded `maxDeletes` — remaining orphans are left for the next run. */
|
|
68
|
+
cappedAtLimit: boolean;
|
|
69
|
+
/** Set when the sweep did nothing because the table scope listed zero tables (fail-closed — see class docs). */
|
|
70
|
+
reason?: 'zero-tables';
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Sweep data files written under a table's `data/` prefix that were NEVER
|
|
74
|
+
* referenced by any snapshot the catalog still retains — a crashed/partial
|
|
75
|
+
* writer's leftovers. This is the one orphan class Cloudflare's own R2 Data
|
|
76
|
+
* Catalog snapshot-expiration GC does not cover.
|
|
77
|
+
*
|
|
78
|
+
* HARD SAFETY:
|
|
79
|
+
* - A listing failure (table load / manifest walk throwing) PROPAGATES.
|
|
80
|
+
* It is never caught and treated as "no live files" — that would make
|
|
81
|
+
* every real data file look orphaned. Callers must let this reject and
|
|
82
|
+
* must not proceed to delete on catch.
|
|
83
|
+
* - Zero tables in scope is treated as "couldn't determine what's live",
|
|
84
|
+
* not "nothing to protect" — the sweep no-ops with `reason: 'zero-tables'`
|
|
85
|
+
* rather than listing/deleting anything.
|
|
86
|
+
* - Deletions are capped at `maxDeletes` per run (default 500).
|
|
87
|
+
* - `dryRun` computes the exact candidate set without ever calling `s3.delete`.
|
|
88
|
+
* - Only ever lists/deletes keys under a scanned table's own
|
|
89
|
+
* `<table.location>/data/` prefix, and every candidate is re-checked to
|
|
90
|
+
* actually start with that prefix before being considered for deletion —
|
|
91
|
+
* never a bare bucket-root listing, never a key outside a data prefix.
|
|
92
|
+
*/
|
|
93
|
+
declare function sweepUncommittedOrphans(opts: SweepUncommittedOrphansOptions): Promise<SweepUncommittedOrphansResult>;
|
|
94
|
+
export { type SweepListPage, type SweepListedObject, type SweepStorageClient, type SweepUncommittedOrphansOptions, type SweepUncommittedOrphansResult, isCommitRateLimited, sweepUncommittedOrphans };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { isCommitRateLimited } from "./_chunks/catalog.mjs";
|
|
2
|
+
import { icebergManifests, restCatalogListTables, restCatalogLoadTable } from "./_chunks/libs/icebird.mjs";
|
|
3
|
+
const DEFAULT_GRACE_HOURS = 48;
|
|
4
|
+
const DEFAULT_MAX_DELETES = 500;
|
|
5
|
+
const DEFAULT_IO_CONCURRENCY = 4;
|
|
6
|
+
const MAX_IO_CONCURRENCY = 32;
|
|
7
|
+
const HOUR_MS = 3600 * 1e3;
|
|
8
|
+
function createIoLimiter(requested) {
|
|
9
|
+
const concurrency = typeof requested === "number" && Number.isFinite(requested) ? Math.max(1, Math.min(MAX_IO_CONCURRENCY, Math.floor(requested))) : DEFAULT_IO_CONCURRENCY;
|
|
10
|
+
let active = 0;
|
|
11
|
+
const waiters = [];
|
|
12
|
+
return async (operation) => {
|
|
13
|
+
if (active >= concurrency) await new Promise((resolve) => waiters.push(resolve));
|
|
14
|
+
active++;
|
|
15
|
+
try {
|
|
16
|
+
return await operation();
|
|
17
|
+
} finally {
|
|
18
|
+
active--;
|
|
19
|
+
waiters.shift()?.();
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
async function settleOrThrow(promises) {
|
|
24
|
+
const settled = await Promise.allSettled(promises);
|
|
25
|
+
const failure = settled.find((result) => result.status === "rejected");
|
|
26
|
+
if (failure) throw failure.reason;
|
|
27
|
+
return settled.map((result) => result.value);
|
|
28
|
+
}
|
|
29
|
+
function splitBucketKey(filePath) {
|
|
30
|
+
if (!filePath.startsWith("s3://")) return null;
|
|
31
|
+
const rest = filePath.slice(5);
|
|
32
|
+
const slash = rest.indexOf("/");
|
|
33
|
+
if (slash < 0) return null;
|
|
34
|
+
return {
|
|
35
|
+
bucket: rest.slice(0, slash),
|
|
36
|
+
key: rest.slice(slash + 1)
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function scanTable(conn, table, bucket, io) {
|
|
40
|
+
const { metadata } = await io(() => restCatalogLoadTable(conn.catalog, {
|
|
41
|
+
namespace: conn.namespace,
|
|
42
|
+
table
|
|
43
|
+
}));
|
|
44
|
+
const location = splitBucketKey(String(metadata.location ?? ""));
|
|
45
|
+
if (!location || location.bucket !== bucket) return {
|
|
46
|
+
dataPrefix: null,
|
|
47
|
+
liveKeys: /* @__PURE__ */ new Set()
|
|
48
|
+
};
|
|
49
|
+
const dataPrefix = `${location.key.replace(/\/+$/, "")}/data/`;
|
|
50
|
+
const liveKeys = /* @__PURE__ */ new Set();
|
|
51
|
+
const manifestsBySnapshot = await settleOrThrow((metadata.snapshots ?? []).map((snapshot) => io(() => icebergManifests({
|
|
52
|
+
metadata,
|
|
53
|
+
resolver: conn.resolver,
|
|
54
|
+
snapshotId: snapshot["snapshot-id"]
|
|
55
|
+
}))));
|
|
56
|
+
for (const manifests of manifestsBySnapshot) for (const manifest of manifests) for (const entry of manifest.entries) {
|
|
57
|
+
if (entry.status === 2) continue;
|
|
58
|
+
const filePath = entry.data_file?.file_path;
|
|
59
|
+
if (!filePath) continue;
|
|
60
|
+
const parsed = splitBucketKey(filePath);
|
|
61
|
+
if (parsed && parsed.bucket === bucket) liveKeys.add(parsed.key);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
dataPrefix,
|
|
65
|
+
liveKeys
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
async function listAllUnderPrefix(s3, prefix, io) {
|
|
69
|
+
const out = [];
|
|
70
|
+
let cursor;
|
|
71
|
+
do {
|
|
72
|
+
const page = await io(() => s3.list({
|
|
73
|
+
prefix,
|
|
74
|
+
cursor
|
|
75
|
+
}));
|
|
76
|
+
out.push(...page.objects);
|
|
77
|
+
cursor = page.truncated ? page.cursor : void 0;
|
|
78
|
+
} while (cursor);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
async function sweepUncommittedOrphans(opts) {
|
|
82
|
+
const { conn, s3, bucket } = opts;
|
|
83
|
+
const graceHours = opts.graceHours ?? DEFAULT_GRACE_HOURS;
|
|
84
|
+
const maxDeletes = opts.maxDeletes ?? DEFAULT_MAX_DELETES;
|
|
85
|
+
const dryRun = opts.dryRun ?? false;
|
|
86
|
+
const graceCutoff = (opts.now ?? Date.now)() - graceHours * HOUR_MS;
|
|
87
|
+
const io = createIoLimiter(opts.ioConcurrency);
|
|
88
|
+
const tables = opts.tables ?? (await io(() => restCatalogListTables(conn.catalog, { namespace: conn.namespace }))).map((t) => t.name);
|
|
89
|
+
if (tables.length === 0) return {
|
|
90
|
+
scannedTables: [],
|
|
91
|
+
skippedTables: [],
|
|
92
|
+
liveFileCount: 0,
|
|
93
|
+
candidateCount: 0,
|
|
94
|
+
deleted: [],
|
|
95
|
+
dryRun,
|
|
96
|
+
cappedAtLimit: false,
|
|
97
|
+
reason: "zero-tables"
|
|
98
|
+
};
|
|
99
|
+
const tableResults = await settleOrThrow(tables.map(async (table) => {
|
|
100
|
+
const { dataPrefix, liveKeys } = await scanTable(conn, table, bucket, io);
|
|
101
|
+
if (!dataPrefix) return {
|
|
102
|
+
table,
|
|
103
|
+
skipped: true,
|
|
104
|
+
liveFileCount: 0,
|
|
105
|
+
candidates: []
|
|
106
|
+
};
|
|
107
|
+
const listed = await listAllUnderPrefix(s3, dataPrefix, io);
|
|
108
|
+
const tableCandidates = [];
|
|
109
|
+
for (const obj of listed) {
|
|
110
|
+
if (!obj.key.startsWith(dataPrefix)) continue;
|
|
111
|
+
if (liveKeys.has(obj.key)) continue;
|
|
112
|
+
if (obj.uploaded.getTime() > graceCutoff) continue;
|
|
113
|
+
tableCandidates.push(obj);
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
table,
|
|
117
|
+
skipped: false,
|
|
118
|
+
liveFileCount: liveKeys.size,
|
|
119
|
+
candidates: tableCandidates
|
|
120
|
+
};
|
|
121
|
+
}));
|
|
122
|
+
const scannedTables = tableResults.filter((result) => !result.skipped).map((result) => result.table);
|
|
123
|
+
const skippedTables = tableResults.filter((result) => result.skipped).map((result) => result.table);
|
|
124
|
+
const liveFileCount = tableResults.reduce((sum, result) => sum + result.liveFileCount, 0);
|
|
125
|
+
const candidates = tableResults.flatMap((result) => result.candidates);
|
|
126
|
+
const candidateCount = candidates.length;
|
|
127
|
+
const toDelete = candidates.slice(0, maxDeletes);
|
|
128
|
+
const cappedAtLimit = candidateCount > maxDeletes;
|
|
129
|
+
if (!dryRun && toDelete.length > 0) await s3.delete(toDelete.map((o) => o.key));
|
|
130
|
+
return {
|
|
131
|
+
scannedTables,
|
|
132
|
+
skippedTables,
|
|
133
|
+
liveFileCount,
|
|
134
|
+
candidateCount,
|
|
135
|
+
deleted: toDelete.map((o) => o.key),
|
|
136
|
+
dryRun,
|
|
137
|
+
cappedAtLimit
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export { isCommitRateLimited, sweepUncommittedOrphans };
|
package/dist/unsafe-raw.d.mts
CHANGED
|
@@ -1,95 +1,3 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { icebergAppendRetrying } from "./_chunks/catalog.mjs";
|
|
2
2
|
import { icebergAppend, icebergCreateTable, icebergDropTable, icebergManifests, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver } from "./_chunks/libs/icebird.mjs";
|
|
3
|
-
|
|
4
|
-
interface SweepListedObject {
|
|
5
|
-
key: string;
|
|
6
|
-
uploaded: Date;
|
|
7
|
-
}
|
|
8
|
-
/** One page of a prefix listing. */
|
|
9
|
-
interface SweepListPage {
|
|
10
|
-
objects: readonly SweepListedObject[];
|
|
11
|
-
truncated: boolean;
|
|
12
|
-
cursor?: string;
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* Minimal storage client `sweepUncommittedOrphans` needs: list-by-prefix +
|
|
16
|
-
* batch delete against the warehouse bucket. Deliberately NOT an S3 SDK
|
|
17
|
-
* client shape — this interface is structurally satisfied by Cloudflare's
|
|
18
|
-
* native `R2Bucket` Workers binding (`env.R2_ANALYTICS.list({ prefix })` /
|
|
19
|
-
* `env.R2_ANALYTICS.delete(keys)`), so a caller running inside a Worker can
|
|
20
|
-
* pass the binding straight through with no adapter. Any other
|
|
21
|
-
* S3-compatible client can be wrapped to this shape.
|
|
22
|
-
*/
|
|
23
|
-
interface SweepStorageClient {
|
|
24
|
-
list: (options: {
|
|
25
|
-
prefix: string;
|
|
26
|
-
cursor?: string;
|
|
27
|
-
}) => Promise<SweepListPage>;
|
|
28
|
-
delete: (keys: string[]) => Promise<void>;
|
|
29
|
-
}
|
|
30
|
-
interface SweepUncommittedOrphansOptions {
|
|
31
|
-
conn: IcebergConnection;
|
|
32
|
-
s3: SweepStorageClient;
|
|
33
|
-
/**
|
|
34
|
-
* Bare R2 bucket name backing `conn`'s warehouse. Every scanned table's
|
|
35
|
-
* `location` must resolve into this exact bucket; a table whose location
|
|
36
|
-
* points elsewhere is SKIPPED — never guessed at, never touched.
|
|
37
|
-
*/
|
|
38
|
-
bucket: string;
|
|
39
|
-
/**
|
|
40
|
-
* Restrict the sweep to these tables; defaults to every table currently
|
|
41
|
-
* in `conn.namespace` (`conn` already carries the namespace scope, so
|
|
42
|
-
* there's no separate `namespace` field here).
|
|
43
|
-
*/
|
|
44
|
-
tables?: readonly string[];
|
|
45
|
-
/** Only delete objects older than this many hours. Default 48. */
|
|
46
|
-
graceHours?: number;
|
|
47
|
-
/** Hard cap on deletions in one run. Default 500. */
|
|
48
|
-
maxDeletes?: number;
|
|
49
|
-
/** Compute the delete set but never call `s3.delete`. Default false. */
|
|
50
|
-
dryRun?: boolean;
|
|
51
|
-
/** Injectable clock (ms epoch). Defaults to `Date.now`. */
|
|
52
|
-
now?: () => number;
|
|
53
|
-
/** Maximum catalog/object-store reads held in flight. Default 4, max 32. */
|
|
54
|
-
ioConcurrency?: number;
|
|
55
|
-
}
|
|
56
|
-
interface SweepUncommittedOrphansResult {
|
|
57
|
-
/** Tables whose retained snapshots were successfully walked. */
|
|
58
|
-
scannedTables: string[];
|
|
59
|
-
/** Tables skipped because their `location` didn't resolve into `bucket` — never scanned, never touched. */
|
|
60
|
-
skippedTables: string[];
|
|
61
|
-
/** Count of live (referenced-by-some-retained-snapshot) files found across `scannedTables`. */
|
|
62
|
-
liveFileCount: number;
|
|
63
|
-
/** Objects under a scanned table's data prefix with no snapshot reference anywhere, past `graceHours`. */
|
|
64
|
-
candidateCount: number;
|
|
65
|
-
/** Keys deleted (or, under `dryRun`, that WOULD be deleted). */
|
|
66
|
-
deleted: string[];
|
|
67
|
-
dryRun: boolean;
|
|
68
|
-
/** True when `candidateCount` exceeded `maxDeletes` — remaining orphans are left for the next run. */
|
|
69
|
-
cappedAtLimit: boolean;
|
|
70
|
-
/** Set when the sweep did nothing because the table scope listed zero tables (fail-closed — see class docs). */
|
|
71
|
-
reason?: 'zero-tables';
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
* Sweep data files written under a table's `data/` prefix that were NEVER
|
|
75
|
-
* referenced by any snapshot the catalog still retains — a crashed/partial
|
|
76
|
-
* writer's leftovers. This is the one orphan class Cloudflare's own R2 Data
|
|
77
|
-
* Catalog snapshot-expiration GC does not cover.
|
|
78
|
-
*
|
|
79
|
-
* HARD SAFETY:
|
|
80
|
-
* - A listing failure (table load / manifest walk throwing) PROPAGATES.
|
|
81
|
-
* It is never caught and treated as "no live files" — that would make
|
|
82
|
-
* every real data file look orphaned. Callers must let this reject and
|
|
83
|
-
* must not proceed to delete on catch.
|
|
84
|
-
* - Zero tables in scope is treated as "couldn't determine what's live",
|
|
85
|
-
* not "nothing to protect" — the sweep no-ops with `reason: 'zero-tables'`
|
|
86
|
-
* rather than listing/deleting anything.
|
|
87
|
-
* - Deletions are capped at `maxDeletes` per run (default 500).
|
|
88
|
-
* - `dryRun` computes the exact candidate set without ever calling `s3.delete`.
|
|
89
|
-
* - Only ever lists/deletes keys under a scanned table's own
|
|
90
|
-
* `<table.location>/data/` prefix, and every candidate is re-checked to
|
|
91
|
-
* actually start with that prefix before being considered for deletion —
|
|
92
|
-
* never a bare bucket-root listing, never a key outside a data prefix.
|
|
93
|
-
*/
|
|
94
|
-
declare function sweepUncommittedOrphans(opts: SweepUncommittedOrphansOptions): Promise<SweepUncommittedOrphansResult>;
|
|
95
|
-
export { type SweepListPage, type SweepListedObject, type SweepStorageClient, type SweepUncommittedOrphansOptions, type SweepUncommittedOrphansResult, icebergAppend, icebergAppendRetrying, icebergCreateTable, icebergDropTable, icebergManifests, isCommitRateLimited, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver, sweepUncommittedOrphans };
|
|
3
|
+
export { icebergAppend, icebergAppendRetrying, icebergCreateTable, icebergDropTable, icebergManifests, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver };
|
package/dist/unsafe-raw.mjs
CHANGED
|
@@ -1,140 +1,3 @@
|
|
|
1
|
-
import { icebergAppendRetrying
|
|
1
|
+
import { icebergAppendRetrying } from "./_chunks/catalog.mjs";
|
|
2
2
|
import { icebergAppend, icebergCreateTable, icebergDropTable, icebergManifests, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver } from "./_chunks/libs/icebird.mjs";
|
|
3
|
-
|
|
4
|
-
const DEFAULT_MAX_DELETES = 500;
|
|
5
|
-
const DEFAULT_IO_CONCURRENCY = 4;
|
|
6
|
-
const MAX_IO_CONCURRENCY = 32;
|
|
7
|
-
const HOUR_MS = 3600 * 1e3;
|
|
8
|
-
function createIoLimiter(requested) {
|
|
9
|
-
const concurrency = typeof requested === "number" && Number.isFinite(requested) ? Math.max(1, Math.min(MAX_IO_CONCURRENCY, Math.floor(requested))) : DEFAULT_IO_CONCURRENCY;
|
|
10
|
-
let active = 0;
|
|
11
|
-
const waiters = [];
|
|
12
|
-
return async (operation) => {
|
|
13
|
-
if (active >= concurrency) await new Promise((resolve) => waiters.push(resolve));
|
|
14
|
-
active++;
|
|
15
|
-
try {
|
|
16
|
-
return await operation();
|
|
17
|
-
} finally {
|
|
18
|
-
active--;
|
|
19
|
-
waiters.shift()?.();
|
|
20
|
-
}
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
async function settleOrThrow(promises) {
|
|
24
|
-
const settled = await Promise.allSettled(promises);
|
|
25
|
-
const failure = settled.find((result) => result.status === "rejected");
|
|
26
|
-
if (failure) throw failure.reason;
|
|
27
|
-
return settled.map((result) => result.value);
|
|
28
|
-
}
|
|
29
|
-
function splitBucketKey(filePath) {
|
|
30
|
-
if (!filePath.startsWith("s3://")) return null;
|
|
31
|
-
const rest = filePath.slice(5);
|
|
32
|
-
const slash = rest.indexOf("/");
|
|
33
|
-
if (slash < 0) return null;
|
|
34
|
-
return {
|
|
35
|
-
bucket: rest.slice(0, slash),
|
|
36
|
-
key: rest.slice(slash + 1)
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
async function scanTable(conn, table, bucket, io) {
|
|
40
|
-
const { metadata } = await io(() => restCatalogLoadTable(conn.catalog, {
|
|
41
|
-
namespace: conn.namespace,
|
|
42
|
-
table
|
|
43
|
-
}));
|
|
44
|
-
const location = splitBucketKey(String(metadata.location ?? ""));
|
|
45
|
-
if (!location || location.bucket !== bucket) return {
|
|
46
|
-
dataPrefix: null,
|
|
47
|
-
liveKeys: /* @__PURE__ */ new Set()
|
|
48
|
-
};
|
|
49
|
-
const dataPrefix = `${location.key.replace(/\/+$/, "")}/data/`;
|
|
50
|
-
const liveKeys = /* @__PURE__ */ new Set();
|
|
51
|
-
const manifestsBySnapshot = await settleOrThrow((metadata.snapshots ?? []).map((snapshot) => io(() => icebergManifests({
|
|
52
|
-
metadata,
|
|
53
|
-
resolver: conn.resolver,
|
|
54
|
-
snapshotId: snapshot["snapshot-id"]
|
|
55
|
-
}))));
|
|
56
|
-
for (const manifests of manifestsBySnapshot) for (const manifest of manifests) for (const entry of manifest.entries) {
|
|
57
|
-
if (entry.status === 2) continue;
|
|
58
|
-
const filePath = entry.data_file?.file_path;
|
|
59
|
-
if (!filePath) continue;
|
|
60
|
-
const parsed = splitBucketKey(filePath);
|
|
61
|
-
if (parsed && parsed.bucket === bucket) liveKeys.add(parsed.key);
|
|
62
|
-
}
|
|
63
|
-
return {
|
|
64
|
-
dataPrefix,
|
|
65
|
-
liveKeys
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
async function listAllUnderPrefix(s3, prefix, io) {
|
|
69
|
-
const out = [];
|
|
70
|
-
let cursor;
|
|
71
|
-
do {
|
|
72
|
-
const page = await io(() => s3.list({
|
|
73
|
-
prefix,
|
|
74
|
-
cursor
|
|
75
|
-
}));
|
|
76
|
-
out.push(...page.objects);
|
|
77
|
-
cursor = page.truncated ? page.cursor : void 0;
|
|
78
|
-
} while (cursor);
|
|
79
|
-
return out;
|
|
80
|
-
}
|
|
81
|
-
async function sweepUncommittedOrphans(opts) {
|
|
82
|
-
const { conn, s3, bucket } = opts;
|
|
83
|
-
const graceHours = opts.graceHours ?? DEFAULT_GRACE_HOURS;
|
|
84
|
-
const maxDeletes = opts.maxDeletes ?? DEFAULT_MAX_DELETES;
|
|
85
|
-
const dryRun = opts.dryRun ?? false;
|
|
86
|
-
const graceCutoff = (opts.now ?? Date.now)() - graceHours * HOUR_MS;
|
|
87
|
-
const io = createIoLimiter(opts.ioConcurrency);
|
|
88
|
-
const tables = opts.tables ?? (await io(() => restCatalogListTables(conn.catalog, { namespace: conn.namespace }))).map((t) => t.name);
|
|
89
|
-
if (tables.length === 0) return {
|
|
90
|
-
scannedTables: [],
|
|
91
|
-
skippedTables: [],
|
|
92
|
-
liveFileCount: 0,
|
|
93
|
-
candidateCount: 0,
|
|
94
|
-
deleted: [],
|
|
95
|
-
dryRun,
|
|
96
|
-
cappedAtLimit: false,
|
|
97
|
-
reason: "zero-tables"
|
|
98
|
-
};
|
|
99
|
-
const tableResults = await settleOrThrow(tables.map(async (table) => {
|
|
100
|
-
const { dataPrefix, liveKeys } = await scanTable(conn, table, bucket, io);
|
|
101
|
-
if (!dataPrefix) return {
|
|
102
|
-
table,
|
|
103
|
-
skipped: true,
|
|
104
|
-
liveFileCount: 0,
|
|
105
|
-
candidates: []
|
|
106
|
-
};
|
|
107
|
-
const listed = await listAllUnderPrefix(s3, dataPrefix, io);
|
|
108
|
-
const tableCandidates = [];
|
|
109
|
-
for (const obj of listed) {
|
|
110
|
-
if (!obj.key.startsWith(dataPrefix)) continue;
|
|
111
|
-
if (liveKeys.has(obj.key)) continue;
|
|
112
|
-
if (obj.uploaded.getTime() > graceCutoff) continue;
|
|
113
|
-
tableCandidates.push(obj);
|
|
114
|
-
}
|
|
115
|
-
return {
|
|
116
|
-
table,
|
|
117
|
-
skipped: false,
|
|
118
|
-
liveFileCount: liveKeys.size,
|
|
119
|
-
candidates: tableCandidates
|
|
120
|
-
};
|
|
121
|
-
}));
|
|
122
|
-
const scannedTables = tableResults.filter((result) => !result.skipped).map((result) => result.table);
|
|
123
|
-
const skippedTables = tableResults.filter((result) => result.skipped).map((result) => result.table);
|
|
124
|
-
const liveFileCount = tableResults.reduce((sum, result) => sum + result.liveFileCount, 0);
|
|
125
|
-
const candidates = tableResults.flatMap((result) => result.candidates);
|
|
126
|
-
const candidateCount = candidates.length;
|
|
127
|
-
const toDelete = candidates.slice(0, maxDeletes);
|
|
128
|
-
const cappedAtLimit = candidateCount > maxDeletes;
|
|
129
|
-
if (!dryRun && toDelete.length > 0) await s3.delete(toDelete.map((o) => o.key));
|
|
130
|
-
return {
|
|
131
|
-
scannedTables,
|
|
132
|
-
skippedTables,
|
|
133
|
-
liveFileCount,
|
|
134
|
-
candidateCount,
|
|
135
|
-
deleted: toDelete.map((o) => o.key),
|
|
136
|
-
dryRun,
|
|
137
|
-
cappedAtLimit
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
export { icebergAppend, icebergAppendRetrying, icebergCreateTable, icebergDropTable, icebergManifests, isCommitRateLimited, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver, sweepUncommittedOrphans };
|
|
3
|
+
export { icebergAppend, icebergAppendRetrying, icebergCreateTable, icebergDropTable, icebergManifests, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gscdump/lakehouse",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "1.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",
|
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
"import": "./dist/index.mjs",
|
|
27
27
|
"default": "./dist/index.mjs"
|
|
28
28
|
},
|
|
29
|
+
"./maintenance": {
|
|
30
|
+
"types": "./dist/maintenance.d.mts",
|
|
31
|
+
"import": "./dist/maintenance.mjs",
|
|
32
|
+
"default": "./dist/maintenance.mjs"
|
|
33
|
+
},
|
|
29
34
|
"./unsafe-raw": {
|
|
30
35
|
"types": "./dist/unsafe-raw.d.mts",
|
|
31
36
|
"import": "./dist/unsafe-raw.mjs",
|
|
@@ -43,7 +48,7 @@
|
|
|
43
48
|
"dist"
|
|
44
49
|
],
|
|
45
50
|
"engines": {
|
|
46
|
-
"node": ">=
|
|
51
|
+
"node": ">=22"
|
|
47
52
|
},
|
|
48
53
|
"devDependencies": {
|
|
49
54
|
"@types/node": "^26.1.1",
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure-JS drop-in replacement for `hysnappy`.
|
|
3
|
-
*
|
|
4
|
-
* `hysnappy`'s `snappyUncompressor()` eagerly compiles a WASM module
|
|
5
|
-
* (`new WebAssembly.Module(byteArray)`) — and `hyparquet-compressors`
|
|
6
|
-
* instantiates it at module top level (`SNAPPY: snappyUncompressor()`).
|
|
7
|
-
* Cloudflare's `workerd` forbids compiling WebAssembly from a runtime buffer
|
|
8
|
-
* (WASM must be a bundled module import), so any Worker bundle that imports
|
|
9
|
-
* `icebird` (→ `hyparquet-compressors`) fails to start with
|
|
10
|
-
* `CompileError: Wasm code generation disallowed by embedder`.
|
|
11
|
-
*
|
|
12
|
-
* `icebird`'s append path never actually decompresses a snappy data file — it
|
|
13
|
-
* only reads gzipped `metadata.json` and Avro manifests — so the snappy
|
|
14
|
-
* decompressor is instantiated but never invoked. This shim swaps the WASM
|
|
15
|
-
* codec for `hyparquet`'s pure-JS snappy decompressor (a vendored snappyjs),
|
|
16
|
-
* keeping the exact `hysnappy` API surface so `hyparquet-compressors` is
|
|
17
|
-
* unaware of the swap. Wired in via a build-time `hysnappy` alias.
|
|
18
|
-
*
|
|
19
|
-
* See `docs/plans/2026-05-22-icebird-ingest-writer-spike.md` (section e).
|
|
20
|
-
*/
|
|
21
|
-
/**
|
|
22
|
-
* Pure-JS stand-in for `hysnappy`'s `snappyUncompressor()`. Returns the
|
|
23
|
-
* decompressor immediately — no WASM compilation, so it is safe to call at
|
|
24
|
-
* module top level inside `workerd`.
|
|
25
|
-
*/
|
|
26
|
-
declare function snappyUncompressor(): (input: Uint8Array, outputLength: number) => Uint8Array;
|
|
27
|
-
/** Pure-JS stand-in for `hysnappy`'s `snappyUncompress(input, outputLength)`. */
|
|
28
|
-
declare function snappyUncompress(input: Uint8Array, outputLength: number): Uint8Array;
|
|
29
|
-
export { snappyUncompress, snappyUncompressor };
|