@gscdump/lakehouse 3.4.2 → 3.4.3

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/dist/catalog.mjs CHANGED
@@ -5,8 +5,7 @@ import { icebergManifests } from "./_virtual/node_modules/.pnpm/icebird@0.8.27_p
5
5
  import { s3SignedResolver } from "./_virtual/node_modules/.pnpm/icebird@0.8.27_patch_hash=651b5cc1b513184638c98e21a0852c4559044ae60f8449fbe9d81a894ea5b4d7/node_modules/icebird/src/s3.mjs";
6
6
  import { cacheGet, cachePut, reportCatalogCacheError } from "./catalog-cache.mjs";
7
7
  import { toIcebergDayCount } from "./date.mjs";
8
- import { wrapManifestCacheResolver } from "./manifest-cache-resolver.mjs";
9
- import { buildManifestPartitionFilter } from "./partition-prune.mjs";
8
+ import { MULTI_MONTH_MANIFEST, buildManifestPartitionFilter, manifestMonthBucket } from "./partition-prune.mjs";
10
9
  let icebirdWriteModule;
11
10
  function useIcebirdWriteModule() {
12
11
  icebirdWriteModule ??= import("./_virtual/node_modules/.pnpm/icebird@0.8.27_patch_hash=651b5cc1b513184638c98e21a0852c4559044ae60f8449fbe9d81a894ea5b4d7/node_modules/icebird/src/write/write.mjs");
@@ -55,8 +54,7 @@ async function connectIcebergCatalog(config, opts = {}) {
55
54
  endpoint: config.s3.endpoint,
56
55
  pathStyle: true
57
56
  });
58
- const baseResolver = opts.cache ? wrapManifestCacheResolver(s3Resolver, opts.cache, cacheScope, opts.clock) : s3Resolver;
59
- const resolver = withVerifiedWriterByteLengths(cachingResolver(baseResolver));
57
+ const resolver = withVerifiedWriterByteLengths(cachingResolver(s3Resolver));
60
58
  return {
61
59
  catalog,
62
60
  resolver,
@@ -234,6 +232,7 @@ const SNAPSHOT_REF_TTL_MS = 18e5;
234
232
  const RESOLVED_FILES_TTL_MS = 864e5;
235
233
  const METADATA_TTL_MS = 864e5;
236
234
  const MAX_CACHED_METADATA_BYTES = 2097152;
235
+ const MONTH_FILES_TTL_MS = 2592e6;
237
236
  function snapshotRefKey(scope, namespace, table) {
238
237
  return `lh-snapref\0${scope}\0${namespace}\0${table}`;
239
238
  }
@@ -246,8 +245,11 @@ async function invalidateSnapshotRef(cache, namespace, table, cacheScope = "") {
246
245
  function metadataRefKey(scope, namespace, table, snapshotId) {
247
246
  return `lh-snapmeta\0${scope}\0${namespace}\0${table}\0${snapshotId}`;
248
247
  }
248
+ function matchKeyOf(matches) {
249
+ return [...matches].map((m) => `${m.field}=${m.value}`).sort().join(",");
250
+ }
249
251
  function resolvedFilesKey(scope, namespace, table, snapshotId, matches, range) {
250
- return `lh-files2\0${scope}\0${namespace}\0${table}\0${snapshotId}\0${[...matches].map((m) => `${m.field}=${m.value}`).sort().join(",")}\0${range.start}..${range.end}`;
252
+ return `lh-files2\0${scope}\0${namespace}\0${table}\0${snapshotId}\0${matchKeyOf(matches)}\0${range.start}..${range.end}`;
251
253
  }
252
254
  function monthsInRange(range) {
253
255
  const [sy, sm] = range.start.split("-").map(Number);
@@ -269,6 +271,18 @@ function monthsSinceEpoch(ym) {
269
271
  const [y, m] = ym.split("-").map(Number);
270
272
  return (y - 1970) * 12 + (m - 1);
271
273
  }
274
+ function ymFromMonthsSinceEpoch(value) {
275
+ const y = 1970 + Math.floor(value / 12);
276
+ const m = (value % 12 + 12) % 12 + 1;
277
+ return `${y}-${String(m).padStart(2, "0")}`;
278
+ }
279
+ function monthFilesKey(scope, namespace, table, matchKey, monthYm, manifestSetHash) {
280
+ return `lh-month\0${scope}\0${namespace}\0${table}\0${matchKey}\0${monthYm}\0${manifestSetHash}`;
281
+ }
282
+ async function sha256Hex(text) {
283
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
284
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
285
+ }
272
286
  const DATE_BOUND_BYTES = 4;
273
287
  function boundBytesForField(map, fieldId) {
274
288
  if (map == null) return null;
@@ -346,11 +360,125 @@ async function loadSnapshotId(conn, namespace, table, cache, now) {
346
360
  metadata
347
361
  };
348
362
  }
363
+ function toListedFile(entry, matches, monthFieldName, wantedMonths, dateFieldId) {
364
+ if (entry.status === 2) return null;
365
+ const df = entry.data_file;
366
+ if (df.content !== 0) return null;
367
+ const part = df.partition;
368
+ for (const match of matches) if (String(part[match.field]) !== String(match.value)) return null;
369
+ if (monthFieldName) {
370
+ const month = part[monthFieldName];
371
+ if (typeof month !== "number" || !wantedMonths.has(month)) return null;
372
+ }
373
+ const dateBounds = dateFieldId == null ? null : readDataFileDateBounds(df, dateFieldId);
374
+ return {
375
+ filePath: df.file_path,
376
+ objectKey: stripBucket(df.file_path),
377
+ bytes: Number(df.file_size_in_bytes),
378
+ rowCount: Number(df.record_count),
379
+ ...dateBounds ? { dateBounds } : {}
380
+ };
381
+ }
382
+ async function resolveViaFullWalk(conn, metadata, partitionFilter, matches, monthFieldName, wantedMonths, dateFieldId) {
383
+ const manifests = await icebergManifests({
384
+ metadata,
385
+ resolver: conn.resolver,
386
+ partitionFilter
387
+ });
388
+ const entries = [];
389
+ for (const m of manifests) for (const entry of m.entries) {
390
+ const file = toListedFile(entry, matches, monthFieldName, wantedMonths, dateFieldId);
391
+ if (file) entries.push(file);
392
+ }
393
+ return {
394
+ entries,
395
+ manifestsWalked: manifests.length,
396
+ monthsWanted: 0,
397
+ monthsHit: 0
398
+ };
399
+ }
400
+ async function resolveViaMonthCache(conn, cache, metadata, partitionFilter, partitionSpec, matches, monthFieldName, wantedMonths, dateFieldId, scope, namespace, table, now) {
401
+ const matchKey = matchKeyOf(matches);
402
+ const manifestMonths = /* @__PURE__ */ new Map();
403
+ await icebergManifests({
404
+ metadata,
405
+ resolver: conn.resolver,
406
+ partitionFilter: (partitions, _specId, manifest) => {
407
+ try {
408
+ if (partitionFilter(partitions) === false) return false;
409
+ manifestMonths.set(manifest.manifest_path, manifestMonthBucket(partitionSpec, partitions));
410
+ } catch {
411
+ manifestMonths.set(manifest.manifest_path, MULTI_MONTH_MANIFEST);
412
+ }
413
+ return false;
414
+ }
415
+ });
416
+ const pathsByMonth = /* @__PURE__ */ new Map();
417
+ for (const [path, bucket] of manifestMonths) {
418
+ if (bucket === "multi" || !wantedMonths.has(bucket)) continue;
419
+ const list = pathsByMonth.get(bucket);
420
+ if (list) list.push(path);
421
+ else pathsByMonth.set(bucket, [path]);
422
+ }
423
+ const monthsWanted = pathsByMonth.size;
424
+ const monthKeys = /* @__PURE__ */ new Map();
425
+ await Promise.all([...pathsByMonth].map(async ([monthValue, paths]) => {
426
+ const hash = await sha256Hex([...paths].sort().join("\0"));
427
+ monthKeys.set(monthValue, monthFilesKey(scope, namespace, table, matchKey, ymFromMonthsSinceEpoch(monthValue), hash));
428
+ }));
429
+ const cachedByMonth = /* @__PURE__ */ new Map();
430
+ await Promise.all([...monthKeys].map(async ([monthValue, key]) => {
431
+ const cached = await cacheGet(cache, key, now);
432
+ if (cached !== void 0) cachedByMonth.set(monthValue, cached);
433
+ }));
434
+ const monthsHit = cachedByMonth.size;
435
+ const toWalk = /* @__PURE__ */ new Set();
436
+ for (const [path, bucket] of manifestMonths) if (bucket === "multi" || !cachedByMonth.has(bucket)) toWalk.add(path);
437
+ const entries = [];
438
+ for (const cached of cachedByMonth.values()) entries.push(...cached);
439
+ let manifestsWalked = 0;
440
+ if (toWalk.size > 0) {
441
+ const manifests = await icebergManifests({
442
+ metadata,
443
+ resolver: conn.resolver,
444
+ partitionFilter: (_partitions, _specId, manifest) => toWalk.has(manifest.manifest_path)
445
+ });
446
+ manifestsWalked = manifests.length;
447
+ const freshByMonth = /* @__PURE__ */ new Map();
448
+ for (const m of manifests) {
449
+ const bucket = manifestMonths.get(m.url);
450
+ if (bucket !== void 0 && bucket !== "multi" && !freshByMonth.has(bucket)) freshByMonth.set(bucket, []);
451
+ for (const entry of m.entries) {
452
+ const file = toListedFile(entry, matches, monthFieldName, wantedMonths, dateFieldId);
453
+ if (!file) continue;
454
+ entries.push(file);
455
+ if (bucket !== void 0 && bucket !== "multi") {
456
+ const list = freshByMonth.get(bucket);
457
+ if (list) list.push(file);
458
+ else freshByMonth.set(bucket, [file]);
459
+ }
460
+ }
461
+ }
462
+ const puts = [];
463
+ for (const [monthValue, files] of freshByMonth) {
464
+ const key = monthKeys.get(monthValue);
465
+ if (key) puts.push(cachePut(cache, key, files, MONTH_FILES_TTL_MS, now));
466
+ }
467
+ await Promise.all(puts);
468
+ }
469
+ return {
470
+ entries,
471
+ manifestsWalked,
472
+ monthsWanted,
473
+ monthsHit
474
+ };
475
+ }
349
476
  async function resolveIcebergDataFiles(conn, opts) {
350
477
  const { namespace, table } = opts;
351
478
  const profiler = opts.profiler;
352
479
  const now = (opts.clock ?? Date.now)();
353
480
  const wantedMonths = new Set(monthsInRange(opts.range).map(monthsSinceEpoch));
481
+ if (wantedMonths.size === 0) return [];
354
482
  const endSnapshot = profiler?.start("iceberg.snapshot");
355
483
  let { snapshotId, metadata } = await loadSnapshotId(conn, namespace, table, opts.cache, now);
356
484
  endSnapshot?.({ cached: metadata == null && snapshotId != null });
@@ -375,49 +503,28 @@ async function resolveIcebergDataFiles(conn, opts) {
375
503
  }
376
504
  const endWalk = profiler?.start("iceberg.walk");
377
505
  const partitionFilter = buildManifestPartitionFilter(opts.partitionSpec, opts.matches, wantedMonths);
378
- const manifests = await icebergManifests({
379
- metadata,
380
- resolver: conn.resolver,
381
- partitionFilter
382
- });
383
506
  const monthField = opts.partitionSpec.find((f) => f.transform === "month");
384
507
  const monthFieldName = monthField?.name;
385
508
  const dateFieldId = dateColumnFieldId(metadata, monthField?.sourceColumn);
386
509
  const dayWindow = rangeDayWindow(opts.range);
510
+ const walk = opts.cache && monthFieldName ? await resolveViaMonthCache(conn, opts.cache, metadata, partitionFilter, opts.partitionSpec, opts.matches, monthFieldName, wantedMonths, dateFieldId, scope, namespace, table, now) : await resolveViaFullWalk(conn, metadata, partitionFilter, opts.matches, monthFieldName, wantedMonths, dateFieldId);
387
511
  let boundsPruned = 0;
388
512
  const out = [];
389
- for (const m of manifests) for (const entry of m.entries) {
390
- if (entry.status === 2) continue;
391
- const df = entry.data_file;
392
- if (df.content !== 0) continue;
393
- const part = df.partition;
394
- let matchesAll = true;
395
- for (const match of opts.matches) if (String(part[match.field]) !== String(match.value)) {
396
- matchesAll = false;
397
- break;
398
- }
399
- if (!matchesAll) continue;
400
- if (monthFieldName) {
401
- const month = part[monthFieldName];
402
- if (typeof month !== "number" || !wantedMonths.has(month)) continue;
403
- }
404
- const dateBounds = dateFieldId == null ? null : readDataFileDateBounds(df, dateFieldId);
405
- if (dateBounds && dayWindow && (dateBounds.maxDay < dayWindow.startDay || dateBounds.minDay > dayWindow.endDay)) {
513
+ for (const file of walk.entries) {
514
+ if (file.dateBounds && dayWindow && (file.dateBounds.maxDay < dayWindow.startDay || file.dateBounds.minDay > dayWindow.endDay)) {
406
515
  boundsPruned++;
407
516
  continue;
408
517
  }
409
- out.push({
410
- filePath: df.file_path,
411
- objectKey: stripBucket(df.file_path),
412
- bytes: Number(df.file_size_in_bytes),
413
- rowCount: Number(df.record_count),
414
- ...dateBounds ? { dateBounds } : {}
415
- });
518
+ out.push(file);
416
519
  }
520
+ out.sort((a, b) => a.filePath < b.filePath ? -1 : a.filePath > b.filePath ? 1 : 0);
417
521
  endWalk?.({
418
- manifests: manifests.length,
522
+ manifests: walk.manifestsWalked,
419
523
  files: out.length,
420
- boundsPruned
524
+ boundsPruned,
525
+ monthsWanted: walk.monthsWanted,
526
+ monthsHit: walk.monthsHit,
527
+ manifestsWalked: walk.manifestsWalked
421
528
  });
422
529
  if (opts.cache) {
423
530
  const freshKey = resolvedFilesKey(scope, namespace, table, snapshotId, opts.matches, opts.range);
@@ -54,4 +54,15 @@ function buildManifestPartitionFilter(partitionSpec, matches, wantedMonths) {
54
54
  return true;
55
55
  };
56
56
  }
57
- export { buildManifestPartitionFilter };
57
+ const MULTI_MONTH_MANIFEST = "multi";
58
+ function manifestMonthBucket(partitionSpec, partitions) {
59
+ const monthFieldIndex = partitionSpec.findIndex((f) => f.transform === "month");
60
+ if (monthFieldIndex < 0 || !partitions || partitions.length <= monthFieldIndex) return MULTI_MONTH_MANIFEST;
61
+ const monthSummary = partitions[monthFieldIndex];
62
+ if (!monthSummary || monthSummary.lower_bound == null && monthSummary.upper_bound == null) return MULTI_MONTH_MANIFEST;
63
+ const lo = decodeMonthInt(monthSummary.lower_bound);
64
+ const hi = decodeMonthInt(monthSummary.upper_bound);
65
+ if (lo == null || hi == null || lo !== hi) return MULTI_MONTH_MANIFEST;
66
+ return lo;
67
+ }
68
+ export { MULTI_MONTH_MANIFEST, buildManifestPartitionFilter, manifestMonthBucket };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/lakehouse",
3
3
  "type": "module",
4
- "version": "3.4.2",
4
+ "version": "3.4.3",
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",
@@ -1,50 +0,0 @@
1
- import { cacheGet, cachePut } from "./catalog-cache.mjs";
2
- const MANIFEST_AVRO_PATH = /\/metadata\/[^/]+\.avro$/;
3
- const MANIFEST_CACHE_TTL_MS = 6048e5;
4
- const MAX_CACHED_MANIFEST_BYTES = 1048576;
5
- const BASE64_CHUNK_BYTES = 32768;
6
- function isManifestObjectPath(path) {
7
- return MANIFEST_AVRO_PATH.test(path);
8
- }
9
- function manifestCacheKey(scope, path) {
10
- return `lh-manifest\0${scope}\0${path}`;
11
- }
12
- function bytesToBase64(bytes) {
13
- let binary = "";
14
- for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_BYTES) {
15
- const chunk = bytes.subarray(offset, offset + BASE64_CHUNK_BYTES);
16
- binary += String.fromCharCode(...chunk);
17
- }
18
- return btoa(binary);
19
- }
20
- function base64ToBytes(b64) {
21
- const binary = atob(b64);
22
- const bytes = new Uint8Array(binary.length);
23
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
24
- return bytes;
25
- }
26
- function bufferedAsyncBuffer(bytes) {
27
- return {
28
- byteLength: bytes.byteLength,
29
- slice(start = 0, end = bytes.byteLength) {
30
- return bytes.buffer.slice(bytes.byteOffset + start, bytes.byteOffset + end);
31
- }
32
- };
33
- }
34
- function wrapManifestCacheResolver(base, cache, scope, clock = Date.now) {
35
- return {
36
- ...base,
37
- async reader(path, byteLength) {
38
- if (!isManifestObjectPath(path)) return base.reader(path, byteLength);
39
- const now = clock();
40
- const key = manifestCacheKey(scope, path);
41
- const cached = await cacheGet(cache, key, now);
42
- if (cached) return bufferedAsyncBuffer(base64ToBytes(cached.b64));
43
- const ab = await base.reader(path, byteLength);
44
- const bytes = new Uint8Array(await ab.slice(0, ab.byteLength));
45
- if (bytes.byteLength <= MAX_CACHED_MANIFEST_BYTES) await cachePut(cache, key, { b64: bytesToBase64(bytes) }, MANIFEST_CACHE_TTL_MS, now);
46
- return bufferedAsyncBuffer(bytes);
47
- }
48
- };
49
- }
50
- export { wrapManifestCacheResolver };