@gscdump/lakehouse 3.4.1 → 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,7 +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 { buildManifestPartitionFilter } from "./partition-prune.mjs";
8
+ import { MULTI_MONTH_MANIFEST, buildManifestPartitionFilter, manifestMonthBucket } from "./partition-prune.mjs";
9
9
  let icebirdWriteModule;
10
10
  function useIcebirdWriteModule() {
11
11
  icebirdWriteModule ??= import("./_virtual/node_modules/.pnpm/icebird@0.8.27_patch_hash=651b5cc1b513184638c98e21a0852c4559044ae60f8449fbe9d81a894ea5b4d7/node_modules/icebird/src/write/write.mjs");
@@ -46,18 +46,20 @@ async function connectIcebergCatalog(config, opts = {}) {
46
46
  await cachePut(opts.cache, catalogConfigKey(config), toCache, CATALOG_CONFIG_TTL_MS, now);
47
47
  }
48
48
  }
49
- const resolver = withVerifiedWriterByteLengths(cachingResolver(s3SignedResolver({
49
+ const cacheScope = catalogCacheScope(config);
50
+ const s3Resolver = s3SignedResolver({
50
51
  accessKeyId: config.s3.accessKeyId,
51
52
  secretAccessKey: config.s3.secretAccessKey,
52
53
  region: config.s3.region ?? "auto",
53
54
  endpoint: config.s3.endpoint,
54
55
  pathStyle: true
55
- })));
56
+ });
57
+ const resolver = withVerifiedWriterByteLengths(cachingResolver(s3Resolver));
56
58
  return {
57
59
  catalog,
58
60
  resolver,
59
61
  namespace: config.namespace,
60
- cacheScope: catalogCacheScope(config)
62
+ cacheScope
61
63
  };
62
64
  }
63
65
  function withVerifiedWriterByteLengths(resolver) {
@@ -230,6 +232,7 @@ const SNAPSHOT_REF_TTL_MS = 18e5;
230
232
  const RESOLVED_FILES_TTL_MS = 864e5;
231
233
  const METADATA_TTL_MS = 864e5;
232
234
  const MAX_CACHED_METADATA_BYTES = 2097152;
235
+ const MONTH_FILES_TTL_MS = 2592e6;
233
236
  function snapshotRefKey(scope, namespace, table) {
234
237
  return `lh-snapref\0${scope}\0${namespace}\0${table}`;
235
238
  }
@@ -242,8 +245,11 @@ async function invalidateSnapshotRef(cache, namespace, table, cacheScope = "") {
242
245
  function metadataRefKey(scope, namespace, table, snapshotId) {
243
246
  return `lh-snapmeta\0${scope}\0${namespace}\0${table}\0${snapshotId}`;
244
247
  }
248
+ function matchKeyOf(matches) {
249
+ return [...matches].map((m) => `${m.field}=${m.value}`).sort().join(",");
250
+ }
245
251
  function resolvedFilesKey(scope, namespace, table, snapshotId, matches, range) {
246
- 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}`;
247
253
  }
248
254
  function monthsInRange(range) {
249
255
  const [sy, sm] = range.start.split("-").map(Number);
@@ -265,6 +271,18 @@ function monthsSinceEpoch(ym) {
265
271
  const [y, m] = ym.split("-").map(Number);
266
272
  return (y - 1970) * 12 + (m - 1);
267
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
+ }
268
286
  const DATE_BOUND_BYTES = 4;
269
287
  function boundBytesForField(map, fieldId) {
270
288
  if (map == null) return null;
@@ -342,11 +360,125 @@ async function loadSnapshotId(conn, namespace, table, cache, now) {
342
360
  metadata
343
361
  };
344
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
+ }
345
476
  async function resolveIcebergDataFiles(conn, opts) {
346
477
  const { namespace, table } = opts;
347
478
  const profiler = opts.profiler;
348
479
  const now = (opts.clock ?? Date.now)();
349
480
  const wantedMonths = new Set(monthsInRange(opts.range).map(monthsSinceEpoch));
481
+ if (wantedMonths.size === 0) return [];
350
482
  const endSnapshot = profiler?.start("iceberg.snapshot");
351
483
  let { snapshotId, metadata } = await loadSnapshotId(conn, namespace, table, opts.cache, now);
352
484
  endSnapshot?.({ cached: metadata == null && snapshotId != null });
@@ -371,49 +503,28 @@ async function resolveIcebergDataFiles(conn, opts) {
371
503
  }
372
504
  const endWalk = profiler?.start("iceberg.walk");
373
505
  const partitionFilter = buildManifestPartitionFilter(opts.partitionSpec, opts.matches, wantedMonths);
374
- const manifests = await icebergManifests({
375
- metadata,
376
- resolver: conn.resolver,
377
- partitionFilter
378
- });
379
506
  const monthField = opts.partitionSpec.find((f) => f.transform === "month");
380
507
  const monthFieldName = monthField?.name;
381
508
  const dateFieldId = dateColumnFieldId(metadata, monthField?.sourceColumn);
382
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);
383
511
  let boundsPruned = 0;
384
512
  const out = [];
385
- for (const m of manifests) for (const entry of m.entries) {
386
- if (entry.status === 2) continue;
387
- const df = entry.data_file;
388
- if (df.content !== 0) continue;
389
- const part = df.partition;
390
- let matchesAll = true;
391
- for (const match of opts.matches) if (String(part[match.field]) !== String(match.value)) {
392
- matchesAll = false;
393
- break;
394
- }
395
- if (!matchesAll) continue;
396
- if (monthFieldName) {
397
- const month = part[monthFieldName];
398
- if (typeof month !== "number" || !wantedMonths.has(month)) continue;
399
- }
400
- const dateBounds = dateFieldId == null ? null : readDataFileDateBounds(df, dateFieldId);
401
- 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)) {
402
515
  boundsPruned++;
403
516
  continue;
404
517
  }
405
- out.push({
406
- filePath: df.file_path,
407
- objectKey: stripBucket(df.file_path),
408
- bytes: Number(df.file_size_in_bytes),
409
- rowCount: Number(df.record_count),
410
- ...dateBounds ? { dateBounds } : {}
411
- });
518
+ out.push(file);
412
519
  }
520
+ out.sort((a, b) => a.filePath < b.filePath ? -1 : a.filePath > b.filePath ? 1 : 0);
413
521
  endWalk?.({
414
- manifests: manifests.length,
522
+ manifests: walk.manifestsWalked,
415
523
  files: out.length,
416
- boundsPruned
524
+ boundsPruned,
525
+ monthsWanted: walk.monthsWanted,
526
+ monthsHit: walk.monthsHit,
527
+ manifestsWalked: walk.manifestsWalked
417
528
  });
418
529
  if (opts.cache) {
419
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.1",
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",