@gscdump/lakehouse 0.38.2 → 0.39.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.
@@ -45,9 +45,10 @@ function toUint8(bytes) {
45
45
  if (bytes == null) return null;
46
46
  return bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
47
47
  }
48
+ const UTF8_DECODER = new TextDecoder();
48
49
  function decodeString(bytes) {
49
50
  const u = toUint8(bytes);
50
- return u == null ? null : new TextDecoder().decode(u);
51
+ return u == null ? null : UTF8_DECODER.decode(u);
51
52
  }
52
53
  function decodeMonthInt(bytes) {
53
54
  const u = toUint8(bytes);
@@ -57,32 +58,40 @@ function decodeMonthInt(bytes) {
57
58
  function buildManifestPartitionFilter(partitionSpec, matches, wantedMonths) {
58
59
  const fieldIndex = (name) => partitionSpec.findIndex((f) => f.name === name || f.sourceColumn === name);
59
60
  const monthFieldIndex = partitionSpec.findIndex((f) => f.transform === "month");
61
+ const stringMatches = matches.flatMap((match) => {
62
+ if (match.encoding !== "string") return [];
63
+ const index = fieldIndex(match.field);
64
+ return index < 0 ? [] : [{
65
+ index,
66
+ value: String(match.value)
67
+ }];
68
+ });
69
+ const wantedMonthValues = wantedMonths ? [...wantedMonths].filter(Number.isFinite).sort((a, b) => a - b) : [];
70
+ const hasWantedMonthInRange = (lo, hi) => {
71
+ let left = 0;
72
+ let right = wantedMonthValues.length;
73
+ while (left < right) {
74
+ const middle = left + right >>> 1;
75
+ if (wantedMonthValues[middle] < lo) left = middle + 1;
76
+ else right = middle;
77
+ }
78
+ return left < wantedMonthValues.length && wantedMonthValues[left] <= hi;
79
+ };
60
80
  return (partitions) => {
61
81
  if (!partitions || partitions.length === 0) return true;
62
- for (const match of matches) {
63
- if (match.encoding !== "string") continue;
64
- const idx = fieldIndex(match.field);
65
- if (idx < 0) continue;
66
- const summary = partitions[idx];
82
+ for (const match of stringMatches) {
83
+ const summary = partitions[match.index];
67
84
  if (!summary || summary.lower_bound == null && summary.upper_bound == null) continue;
68
85
  const lo = decodeString(summary.lower_bound);
69
86
  const hi = decodeString(summary.upper_bound);
70
- const wantStr = String(match.value);
71
- if (lo != null && hi != null && (wantStr < lo || wantStr > hi)) return false;
87
+ if (lo != null && hi != null && (match.value < lo || match.value > hi)) return false;
72
88
  }
73
- if (wantedMonths && wantedMonths.size > 0 && monthFieldIndex >= 0) {
89
+ if (wantedMonthValues.length > 0 && monthFieldIndex >= 0) {
74
90
  const monthSummary = partitions[monthFieldIndex];
75
91
  if (monthSummary && (monthSummary.lower_bound != null || monthSummary.upper_bound != null)) {
76
92
  const lo = decodeMonthInt(monthSummary.lower_bound);
77
93
  const hi = decodeMonthInt(monthSummary.upper_bound);
78
- if (lo != null && hi != null) {
79
- let anyInRange = false;
80
- for (const wm of wantedMonths) if (wm >= lo && wm <= hi) {
81
- anyInRange = true;
82
- break;
83
- }
84
- if (!anyInRange) return false;
85
- }
94
+ if (lo != null && hi != null && !hasWantedMonthInRange(lo, hi)) return false;
86
95
  }
87
96
  }
88
97
  return true;
package/dist/index.mjs CHANGED
@@ -122,6 +122,7 @@ function buildRowProcessor(def, tableSpec) {
122
122
  const idNames = identityColumnNames(def.identity);
123
123
  const idEncodings = identityEncodings(def.identity);
124
124
  const dimNames = def.dims ? Object.keys(def.dims) : [];
125
+ const identityColumns = tableSpec.identityColumns;
125
126
  function guard(row) {
126
127
  const out = {};
127
128
  for (const name of idNames) {
@@ -142,11 +143,14 @@ function buildRowProcessor(def, tableSpec) {
142
143
  }
143
144
  function dedupe(rows) {
144
145
  if (rows.length < 2) return rows;
145
- const keyCols = tableSpec.identityColumns;
146
146
  const seen = /* @__PURE__ */ new Map();
147
147
  for (const rec of rows) {
148
- const k = keyCols.map((c) => `${rec[c] ?? ""}`).join("\0");
149
- seen.set(k, rec);
148
+ let identity = "";
149
+ for (let index = 0; index < identityColumns.length; index++) {
150
+ if (index > 0) identity += "\0";
151
+ identity += `${rec[identityColumns[index]] ?? ""}`;
152
+ }
153
+ seen.set(identity, rec);
150
154
  }
151
155
  return seen.size === rows.length ? rows : [...seen.values()];
152
156
  }
@@ -50,6 +50,8 @@ interface SweepUncommittedOrphansOptions {
50
50
  dryRun?: boolean;
51
51
  /** Injectable clock (ms epoch). Defaults to `Date.now`. */
52
52
  now?: () => number;
53
+ /** Maximum catalog/object-store reads held in flight. Default 4, max 32. */
54
+ ioConcurrency?: number;
53
55
  }
54
56
  interface SweepUncommittedOrphansResult {
55
57
  /** Tables whose retained snapshots were successfully walked. */
@@ -2,7 +2,30 @@ import { icebergAppendRetrying, isCommitRateLimited } from "./_chunks/catalog.mj
2
2
  import { icebergAppend, icebergCreateTable, icebergDropTable, icebergManifests, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver } from "./_chunks/libs/icebird.mjs";
3
3
  const DEFAULT_GRACE_HOURS = 48;
4
4
  const DEFAULT_MAX_DELETES = 500;
5
+ const DEFAULT_IO_CONCURRENCY = 4;
6
+ const MAX_IO_CONCURRENCY = 32;
5
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
+ }
6
29
  function splitBucketKey(filePath) {
7
30
  if (!filePath.startsWith("s3://")) return null;
8
31
  const rest = filePath.slice(5);
@@ -13,11 +36,11 @@ function splitBucketKey(filePath) {
13
36
  key: rest.slice(slash + 1)
14
37
  };
15
38
  }
16
- async function scanTable(conn, table, bucket) {
17
- const { metadata } = await restCatalogLoadTable(conn.catalog, {
39
+ async function scanTable(conn, table, bucket, io) {
40
+ const { metadata } = await io(() => restCatalogLoadTable(conn.catalog, {
18
41
  namespace: conn.namespace,
19
42
  table
20
- });
43
+ }));
21
44
  const location = splitBucketKey(String(metadata.location ?? ""));
22
45
  if (!location || location.bucket !== bucket) return {
23
46
  dataPrefix: null,
@@ -25,34 +48,31 @@ async function scanTable(conn, table, bucket) {
25
48
  };
26
49
  const dataPrefix = `${location.key.replace(/\/+$/, "")}/data/`;
27
50
  const liveKeys = /* @__PURE__ */ new Set();
28
- const snapshots = metadata.snapshots ?? [];
29
- for (const snapshot of snapshots) {
30
- const manifests = await icebergManifests({
31
- metadata,
32
- resolver: conn.resolver,
33
- snapshotId: snapshot["snapshot-id"]
34
- });
35
- for (const manifest of manifests) for (const entry of manifest.entries) {
36
- if (entry.status === 2) continue;
37
- const filePath = entry.data_file?.file_path;
38
- if (!filePath) continue;
39
- const parsed = splitBucketKey(filePath);
40
- if (parsed && parsed.bucket === bucket) liveKeys.add(parsed.key);
41
- }
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);
42
62
  }
43
63
  return {
44
64
  dataPrefix,
45
65
  liveKeys
46
66
  };
47
67
  }
48
- async function listAllUnderPrefix(s3, prefix) {
68
+ async function listAllUnderPrefix(s3, prefix, io) {
49
69
  const out = [];
50
70
  let cursor;
51
71
  do {
52
- const page = await s3.list({
72
+ const page = await io(() => s3.list({
53
73
  prefix,
54
74
  cursor
55
- });
75
+ }));
56
76
  out.push(...page.objects);
57
77
  cursor = page.truncated ? page.cursor : void 0;
58
78
  } while (cursor);
@@ -64,7 +84,8 @@ async function sweepUncommittedOrphans(opts) {
64
84
  const maxDeletes = opts.maxDeletes ?? DEFAULT_MAX_DELETES;
65
85
  const dryRun = opts.dryRun ?? false;
66
86
  const graceCutoff = (opts.now ?? Date.now)() - graceHours * HOUR_MS;
67
- const tables = opts.tables ?? (await restCatalogListTables(conn.catalog, { namespace: conn.namespace })).map((t) => t.name);
87
+ const io = createIoLimiter(opts.ioConcurrency);
88
+ const tables = opts.tables ?? (await io(() => restCatalogListTables(conn.catalog, { namespace: conn.namespace }))).map((t) => t.name);
68
89
  if (tables.length === 0) return {
69
90
  scannedTables: [],
70
91
  skippedTables: [],
@@ -75,26 +96,33 @@ async function sweepUncommittedOrphans(opts) {
75
96
  cappedAtLimit: false,
76
97
  reason: "zero-tables"
77
98
  };
78
- const scannedTables = [];
79
- const skippedTables = [];
80
- let liveFileCount = 0;
81
- const candidates = [];
82
- for (const table of tables) {
83
- const { dataPrefix, liveKeys } = await scanTable(conn, table, bucket);
84
- if (!dataPrefix) {
85
- skippedTables.push(table);
86
- continue;
87
- }
88
- scannedTables.push(table);
89
- liveFileCount += liveKeys.size;
90
- const listed = await listAllUnderPrefix(s3, dataPrefix);
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 = [];
91
109
  for (const obj of listed) {
92
110
  if (!obj.key.startsWith(dataPrefix)) continue;
93
111
  if (liveKeys.has(obj.key)) continue;
94
112
  if (obj.uploaded.getTime() > graceCutoff) continue;
95
- candidates.push(obj);
113
+ tableCandidates.push(obj);
96
114
  }
97
- }
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);
98
126
  const candidateCount = candidates.length;
99
127
  const toDelete = candidates.slice(0, maxDeletes);
100
128
  const cappedAtLimit = candidateCount > maxDeletes;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/lakehouse",
3
3
  "type": "module",
4
- "version": "0.38.2",
4
+ "version": "0.39.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",