@gscdump/lakehouse 1.3.1 → 1.3.2

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.
@@ -1,5 +1,5 @@
1
1
  import { stringifyBigintSafe } from "../bigint.mjs";
2
- import { cachingResolver, icebergAppend, icebergDropTable, icebergManifests, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver } from "./libs/icebird.mjs";
2
+ import { cachingResolver, icebergAppend, icebergAppendBatches, icebergDropTable, icebergManifests, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver } from "./libs/icebird.mjs";
3
3
  function reportCatalogCacheError(cache, operation, key, error) {
4
4
  try {
5
5
  if (cache.onError) cache.onError(operation, key, error);
@@ -240,6 +240,36 @@ async function icebergAppendRetrying(args, options = {}) {
240
240
  await sleep(Math.floor(random() * ceiling));
241
241
  }
242
242
  }
243
+ async function icebergAppendBatchesRetrying(args, options) {
244
+ const maxAttempts = options.maxAttempts ?? 6;
245
+ const baseDelayMs = options.baseDelayMs ?? 1e3;
246
+ const maxDelayMs = options.maxDelayMs ?? 2e4;
247
+ const sleep = options.sleep ?? defaultCommitSleep;
248
+ const random = options.random ?? Math.random;
249
+ const appendId = options.appendId;
250
+ const { batchFactory, ...appendArgs } = args;
251
+ const stampedArgs = {
252
+ ...appendArgs,
253
+ snapshotProperties: {
254
+ ...appendArgs.snapshotProperties,
255
+ [APPEND_ID_SUMMARY_KEY]: appendId
256
+ }
257
+ };
258
+ if (await appendAlreadyLanded(args, appendId)) return false;
259
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
260
+ const err = await icebergAppendBatches({
261
+ ...stampedArgs,
262
+ batches: batchFactory()
263
+ }).then(() => void 0, (e) => e);
264
+ if (err === void 0) return true;
265
+ if (!isCommitRateLimited(err)) throw err;
266
+ if (await appendAlreadyLanded(args, appendId)) return true;
267
+ if (attempt === maxAttempts - 1) throw err;
268
+ const ceiling = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
269
+ await sleep(Math.floor(random() * ceiling));
270
+ }
271
+ return false;
272
+ }
243
273
  async function deriveAppendId(args) {
244
274
  const records = args.records ?? [];
245
275
  if (records.length === 0) return globalThis.crypto.randomUUID();
@@ -399,4 +429,4 @@ async function resolveIcebergDataFiles(conn, opts) {
399
429
  }
400
430
  return out;
401
431
  }
402
- export { ICEBERG_TYPE_MAP, buildManifestPartitionFilter, cacheGet, cachePut, catalogCacheScope, connectIcebergCatalog, dropIcebergTables, ensureIcebergNamespace, icebergAppendRetrying, invalidateSnapshotRef, isCommitRateLimited, listIcebergTables, resolveIcebergDataFiles };
432
+ export { ICEBERG_TYPE_MAP, buildManifestPartitionFilter, cacheGet, cachePut, catalogCacheScope, connectIcebergCatalog, dropIcebergTables, ensureIcebergNamespace, icebergAppendBatchesRetrying, icebergAppendRetrying, invalidateSnapshotRef, isCommitRateLimited, listIcebergTables, resolveIcebergDataFiles };
@@ -3145,7 +3145,7 @@ function compareKeys(ka, kb, resultType, desc, nullsFirst) {
3145
3145
  const c = compare(ka, kb, resultType);
3146
3146
  return desc ? -c : c;
3147
3147
  }
3148
- async function prepareAppend({ tableUrl, metadata, records, resolver, sortOrderId }) {
3148
+ async function prepareAppend({ tableUrl, metadata, records, resolver, sortOrderId, snapshotId }) {
3149
3149
  if (!tableUrl) throw new Error("tableUrl is required");
3150
3150
  if (!resolver?.writer) throw new Error("resolver.writer is required");
3151
3151
  const writerFn = resolver.writer;
@@ -3156,7 +3156,7 @@ async function prepareAppend({ tableUrl, metadata, records, resolver, sortOrderI
3156
3156
  const schema = metadata.schemas.find((s) => s["schema-id"] === metadata["current-schema-id"]);
3157
3157
  if (!schema) throw new Error("current schema not found in metadata");
3158
3158
  validateSchemaForVersion(schema, formatVersion);
3159
- const snapshotId = newSnapshotId(metadata);
3159
+ const appendSnapshotId = snapshotId ?? newSnapshotId(metadata);
3160
3160
  const manifestUuid = uuid4();
3161
3161
  checkWriteFormat(metadata.properties?.["write.format.default"]);
3162
3162
  const codec = resolveParquetCodec(metadata.properties?.["write.parquet.compression-codec"]);
@@ -3206,7 +3206,7 @@ async function prepareAppend({ tableUrl, metadata, records, resolver, sortOrderI
3206
3206
  writer: manifestWriter,
3207
3207
  schema,
3208
3208
  partitionSpec,
3209
- snapshotId,
3209
+ snapshotId: appendSnapshotId,
3210
3210
  dataFiles: writtenDataFiles.map((f) => f.dataFile),
3211
3211
  formatVersion
3212
3212
  });
@@ -3215,7 +3215,7 @@ async function prepareAppend({ tableUrl, metadata, records, resolver, sortOrderI
3215
3215
  const addedFilesSize = writtenDataFiles.reduce((sum, f) => sum + f.dataFile.file_size_in_bytes, 0n);
3216
3216
  const partitions = buildPartitionSummaries(writtenDataFiles.map((f) => f.dataFile.partition), schema, partitionSpec);
3217
3217
  return {
3218
- snapshotId,
3218
+ snapshotId: appendSnapshotId,
3219
3219
  manifestUuid,
3220
3220
  formatVersion,
3221
3221
  manifestPath,
@@ -3284,6 +3284,70 @@ async function stageSnapshotForAppend({ tableUrl, metadata, prepared, resolver,
3284
3284
  writtenFiles: []
3285
3285
  });
3286
3286
  }
3287
+ async function stageSnapshotForAppendBatches({ tableUrl, metadata, prepared, resolver, snapshotProperties }) {
3288
+ if (!tableUrl) throw new Error("tableUrl is required");
3289
+ if (!resolver?.writer) throw new Error("resolver.writer is required");
3290
+ if (prepared.length === 0) throw new Error("prepared batches must not be empty");
3291
+ const first = prepared[0];
3292
+ for (const batch of prepared) {
3293
+ if (batch.snapshotId !== first.snapshotId) throw new Error("prepared batches must share one snapshot id");
3294
+ if (batch.formatVersion !== first.formatVersion) throw new Error("prepared batches must share one format version");
3295
+ }
3296
+ const sequenceNumber = BigInt(metadata["last-sequence-number"] ?? 0) + 1n;
3297
+ const timestampMs = Date.now();
3298
+ const newManifests = prepared.map((batch) => ({
3299
+ manifest_path: batch.manifestPath,
3300
+ manifest_length: batch.manifestLength,
3301
+ partition_spec_id: batch.partitionSpecId,
3302
+ content: 0,
3303
+ sequence_number: sequenceNumber,
3304
+ min_sequence_number: sequenceNumber,
3305
+ added_snapshot_id: first.snapshotId,
3306
+ added_files_count: batch.addedDataFilesCount,
3307
+ existing_files_count: 0,
3308
+ deleted_files_count: 0,
3309
+ added_rows_count: batch.addedRowCount,
3310
+ existing_rows_count: 0n,
3311
+ deleted_rows_count: 0n,
3312
+ partitions: batch.partitions
3313
+ }));
3314
+ const addedDataFilesCount = prepared.reduce((sum, batch) => sum + batch.addedDataFilesCount, 0);
3315
+ const recordsCount = prepared.reduce((sum, batch) => sum + batch.recordsCount, 0);
3316
+ const addedFilesSize = prepared.reduce((sum, batch) => sum + batch.addedFilesSize, 0n);
3317
+ const prevSummary = currentSnapshot(metadata)?.summary;
3318
+ const prevTotals = {
3319
+ records: BigInt(prevSummary?.["total-records"] ?? "0"),
3320
+ size: BigInt(prevSummary?.["total-files-size"] ?? "0"),
3321
+ files: BigInt(prevSummary?.["total-data-files"] ?? "0")
3322
+ };
3323
+ const summary = {
3324
+ operation: "append",
3325
+ "added-data-files": String(addedDataFilesCount),
3326
+ "added-records": String(recordsCount),
3327
+ "added-files-size": String(addedFilesSize),
3328
+ "changed-partition-count": String(addedDataFilesCount),
3329
+ "total-records": String(prevTotals.records + BigInt(recordsCount)),
3330
+ "total-files-size": String(prevTotals.size + addedFilesSize),
3331
+ "total-data-files": String(prevTotals.files + BigInt(addedDataFilesCount)),
3332
+ "total-delete-files": "0",
3333
+ "total-position-deletes": "0",
3334
+ "total-equality-deletes": "0",
3335
+ ...snapshotProperties ?? {}
3336
+ };
3337
+ return await buildSnapshotUpdate({
3338
+ tableUrl,
3339
+ metadata,
3340
+ resolver,
3341
+ snapshotId: first.snapshotId,
3342
+ sequenceNumber,
3343
+ manifestUuid: first.manifestUuid,
3344
+ timestampMs,
3345
+ formatVersion: first.formatVersion,
3346
+ newManifests,
3347
+ summary,
3348
+ writtenFiles: []
3349
+ });
3350
+ }
3287
3351
  function checkWriteFormat(value) {
3288
3352
  if (value === void 0) return;
3289
3353
  if (value.toLowerCase() !== "parquet") throw new Error(`unsupported write.format.default: ${value}`);
@@ -3415,6 +3479,47 @@ async function icebergAppend({ catalog, namespace, table, tableUrl, resolver, re
3415
3479
  })
3416
3480
  });
3417
3481
  }
3482
+ async function icebergAppendBatches({ catalog, namespace, table, tableUrl, resolver, batches, sortOrderId, snapshotProperties }) {
3483
+ const ctx = await loadTable({
3484
+ catalog,
3485
+ namespace,
3486
+ table,
3487
+ tableUrl,
3488
+ resolver
3489
+ });
3490
+ const writer = requireResolver(ctx.resolver, "icebergAppendBatches");
3491
+ const prepared = [];
3492
+ let snapshotId;
3493
+ for await (const records of batches) {
3494
+ if (records.length === 0) continue;
3495
+ const batch = await prepareAppend({
3496
+ tableUrl: ctx.tableUrl,
3497
+ metadata: ctx.metadata,
3498
+ records,
3499
+ resolver: writer,
3500
+ sortOrderId,
3501
+ snapshotId
3502
+ });
3503
+ snapshotId ??= batch.snapshotId;
3504
+ prepared.push(batch);
3505
+ }
3506
+ if (prepared.length === 0) return ctx.metadata;
3507
+ return await commitWithRetry({
3508
+ catalog,
3509
+ target: {
3510
+ namespace,
3511
+ table
3512
+ },
3513
+ ctx,
3514
+ stage: (workingCtx) => stageSnapshotForAppendBatches({
3515
+ tableUrl: workingCtx.tableUrl,
3516
+ metadata: workingCtx.metadata,
3517
+ prepared,
3518
+ resolver: requireResolver(workingCtx.resolver, "icebergAppendBatches"),
3519
+ snapshotProperties
3520
+ })
3521
+ });
3522
+ }
3418
3523
  async function icebergCreateTable({ catalog, namespace, table, tableUrl, schema, partitionSpec, sortOrder, properties, formatVersion, stageCreate }) {
3419
3524
  if (catalog.type === "rest") {
3420
3525
  if (!namespace || !table) throw new Error("namespace and table are required for rest catalogs");
@@ -3746,4 +3851,4 @@ function s3SignedResolver({ accessKeyId, secretAccessKey, sessionToken, region,
3746
3851
  return () => new Promise((resolve) => setTimeout(resolve, 0));
3747
3852
  })();
3748
3853
  new TextEncoder();
3749
- export { cachingResolver, icebergAppend, icebergCreateTable, icebergDropTable, icebergManifests, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver };
3854
+ export { cachingResolver, icebergAppend, icebergAppendBatches, icebergCreateTable, icebergDropTable, icebergManifests, restCatalogConnect, restCatalogCreateNamespace, restCatalogListTables, restCatalogLoadTable, s3SignedResolver };
package/dist/index.d.mts CHANGED
@@ -83,6 +83,15 @@ interface AppendResult {
83
83
  /** Rows dropped by the identity guard (missing/out-of-range required identity value). */
84
84
  skipped: number;
85
85
  }
86
+ type AppendBatchSource = () => Iterable<readonly Record<string, unknown>[]> | AsyncIterable<readonly Record<string, unknown>[]>;
87
+ interface AppendBatchesOptions extends AppendCommitOptions {
88
+ /** Stable across job retries; recorded in snapshot metadata for landed checks. */
89
+ appendId: string;
90
+ }
91
+ interface AppendBatchesResult extends AppendResult {
92
+ /** False when this append id was already present before the call. */
93
+ committed: boolean;
94
+ }
86
95
  interface AppendSinkCloseResult extends AppendResult {
87
96
  flushed: boolean;
88
97
  error?: unknown;
@@ -112,6 +121,12 @@ interface IcebergDataset {
112
121
  * date column pre-converted via {@link toIcebergDayCount}.
113
122
  */
114
123
  appendRows: (conn: IcebergConnection, rows: readonly Record<string, unknown>[], opts?: AppendCommitOptions) => Promise<AppendResult>;
124
+ /**
125
+ * Lazily process bounded row batches, writing multiple data files into one
126
+ * append snapshot and one catalog commit. Dedupe keys must be disjoint
127
+ * across source batches.
128
+ */
129
+ appendBatches: (conn: IcebergConnection, source: AppendBatchSource, opts: AppendBatchesOptions) => Promise<AppendBatchesResult>;
115
130
  /**
116
131
  * PURE row processing — the identity INT32 guard, dedupe (identity+dims+
117
132
  * naturalKey, last-wins) and cluster pre-sort `appendRows`/`appendSink`
@@ -143,4 +158,4 @@ interface IcebergDataset {
143
158
  * from the def. See {@link IcebergDatasetDef}.
144
159
  */
145
160
  declare function defineIcebergDataset(def: IcebergDatasetDef): IcebergDataset;
146
- export { type AppendCommitOptions, type AppendResult, type AppendSink, type AppendSinkCloseResult, type AppendSinkOptions, type CatalogCache, type CommitRetryOptions, type ConnectIcebergOptions, DEFAULT_PARTITION_KEY_ENCODING, type DatasetDim, type DatasetIdentity, ICEBERG_TYPE_MAP, type IcebergCatalogConfig, type IcebergColumn, type IcebergColumnType, type IcebergConnection, type IcebergDataset, type IcebergDatasetColumnDef, type IcebergDatasetDef, type IcebergDatasetLedger, type IcebergFieldSummary, type IcebergListedDataFile, type IcebergPartitionField, type IcebergPartitionSpec, type IcebergPartitionSpecField, type IcebergPartitionTransform, type IcebergPrimitiveType, type IcebergS3Config, type IcebergSchema, type IcebergSchemaField, type IcebergSortOrder, type IcebergSortOrderField, type IcebergTableOpResult, type IcebergTableSpec, type ManifestPartitionFilter, type PartitionKeyEncoding, type PartitionValueMatch, type QueryProfiler, type ResolveDataFilesOptions, type ResolveIcebergDataFilesOptions, bigintJsonReplacer, buildManifestPartitionFilter, cacheGet, cachePut, catalogCacheScope, coerceBigIntToNumber, connectIcebergCatalog, defineIcebergDataset, deriveTableSpec, dropIcebergTables, encodeJsonBigintSafe, ensureIcebergNamespace, invalidateSnapshotRef, listIcebergTables, resolveIcebergDataFiles, stringifyBigintSafe, toIcebergDayCount };
161
+ export { type AppendBatchSource, type AppendBatchesOptions, type AppendBatchesResult, type AppendCommitOptions, type AppendResult, type AppendSink, type AppendSinkCloseResult, type AppendSinkOptions, type CatalogCache, type CommitRetryOptions, type ConnectIcebergOptions, DEFAULT_PARTITION_KEY_ENCODING, type DatasetDim, type DatasetIdentity, ICEBERG_TYPE_MAP, type IcebergCatalogConfig, type IcebergColumn, type IcebergColumnType, type IcebergConnection, type IcebergDataset, type IcebergDatasetColumnDef, type IcebergDatasetDef, type IcebergDatasetLedger, type IcebergFieldSummary, type IcebergListedDataFile, type IcebergPartitionField, type IcebergPartitionSpec, type IcebergPartitionSpecField, type IcebergPartitionTransform, type IcebergPrimitiveType, type IcebergS3Config, type IcebergSchema, type IcebergSchemaField, type IcebergSortOrder, type IcebergSortOrderField, type IcebergTableOpResult, type IcebergTableSpec, type ManifestPartitionFilter, type PartitionKeyEncoding, type PartitionValueMatch, type QueryProfiler, type ResolveDataFilesOptions, type ResolveIcebergDataFilesOptions, bigintJsonReplacer, buildManifestPartitionFilter, cacheGet, cachePut, catalogCacheScope, coerceBigIntToNumber, connectIcebergCatalog, defineIcebergDataset, deriveTableSpec, dropIcebergTables, encodeJsonBigintSafe, ensureIcebergNamespace, invalidateSnapshotRef, listIcebergTables, resolveIcebergDataFiles, stringifyBigintSafe, toIcebergDayCount };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { bigintJsonReplacer, coerceBigIntToNumber, encodeJsonBigintSafe, stringifyBigintSafe } from "./bigint.mjs";
2
2
  import { icebergCreateTable } from "./_chunks/libs/icebird.mjs";
3
- import { ICEBERG_TYPE_MAP, buildManifestPartitionFilter, cacheGet, cachePut, catalogCacheScope, connectIcebergCatalog, dropIcebergTables, ensureIcebergNamespace, icebergAppendRetrying, invalidateSnapshotRef, listIcebergTables, resolveIcebergDataFiles } from "./_chunks/catalog.mjs";
3
+ import { ICEBERG_TYPE_MAP, buildManifestPartitionFilter, cacheGet, cachePut, catalogCacheScope, connectIcebergCatalog, dropIcebergTables, ensureIcebergNamespace, icebergAppendBatchesRetrying, icebergAppendRetrying, invalidateSnapshotRef, listIcebergTables, resolveIcebergDataFiles } from "./_chunks/catalog.mjs";
4
4
  import { DEFAULT_PARTITION_KEY_ENCODING } from "./schema.mjs";
5
5
  const INT32_MIN = -2147483648;
6
6
  const INT32_MAX = 2147483647;
@@ -243,6 +243,35 @@ function defineIcebergDataset(def) {
243
243
  skipped
244
244
  };
245
245
  }
246
+ async function appendBatches(conn, source, opts) {
247
+ let accepted = 0;
248
+ let skipped = 0;
249
+ const batchFactory = async function* () {
250
+ accepted = 0;
251
+ skipped = 0;
252
+ for await (const rows of source()) {
253
+ const prepared = process(rows);
254
+ accepted += prepared.records.length;
255
+ skipped += prepared.skipped;
256
+ if (prepared.records.length > 0) yield prepared.records;
257
+ }
258
+ };
259
+ const committed = await icebergAppendBatchesRetrying({
260
+ catalog: conn.catalog,
261
+ namespace: conn.namespace,
262
+ table: def.table,
263
+ resolver: conn.resolver,
264
+ batchFactory
265
+ }, {
266
+ ...opts.commitRetry,
267
+ appendId: opts.appendId
268
+ });
269
+ return {
270
+ accepted,
271
+ skipped,
272
+ committed
273
+ };
274
+ }
246
275
  function appendSink(opts) {
247
276
  let buffer = [];
248
277
  let connection;
@@ -323,6 +352,7 @@ function defineIcebergDataset(def) {
323
352
  icebergSortOrder: () => sortOrder,
324
353
  createTable,
325
354
  appendRows,
355
+ appendBatches,
326
356
  prepareRows: process,
327
357
  appendSink,
328
358
  readerPredicate,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/lakehouse",
3
3
  "type": "module",
4
- "version": "1.3.1",
4
+ "version": "1.3.2",
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",