@gscdump/engine 0.38.1 → 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.
@@ -189,42 +189,46 @@ function splitOverlappingTiers(entries, queryRange) {
189
189
  const spanned = [];
190
190
  const kept = [];
191
191
  const subsumed = [];
192
+ const clampToRange = Number.isFinite(rangeStartMs) && Number.isFinite(rangeEndMs);
192
193
  for (const entry of entries) {
193
194
  const span = partitionSpan(entry.partition);
194
195
  if (!span) {
195
196
  kept.push(entry);
196
197
  continue;
197
198
  }
198
- const days = [];
199
- for (let t = span.startMs; t <= span.endMs; t += MS_PER_DAY) {
200
- if (rangeStartMs !== void 0 && (t < rangeStartMs || t > rangeEndMs)) continue;
201
- days.push(t);
202
- }
203
- if (queryRange && days.length === 0) {
199
+ const startMs = clampToRange ? Math.max(span.startMs, rangeStartMs) : span.startMs;
200
+ const endMs = clampToRange ? Math.min(span.endMs, rangeEndMs) : span.endMs;
201
+ if (queryRange && startMs > endMs) {
204
202
  subsumed.push(entry);
205
203
  continue;
206
204
  }
207
205
  spanned.push({
208
206
  entry,
209
207
  rank: span.rank,
210
- days
208
+ startMs,
209
+ endMs
211
210
  });
212
211
  }
213
212
  spanned.sort((a, b) => a.rank - b.rank || b.entry.createdAt - a.entry.createdAt);
214
213
  const coveredBySearchType = /* @__PURE__ */ new Map();
215
- for (const { entry, days } of spanned) {
214
+ for (const { entry, startMs, endMs } of spanned) {
216
215
  const slice = inferSearchType(entry);
217
216
  let covered = coveredBySearchType.get(slice);
218
217
  if (!covered) {
219
218
  covered = /* @__PURE__ */ new Set();
220
219
  coveredBySearchType.set(slice, covered);
221
220
  }
222
- if (days.every((d) => covered.has(d))) {
221
+ let fullyCovered = true;
222
+ for (let dayMs = startMs; dayMs <= endMs; dayMs += MS_PER_DAY) if (!covered.has(dayMs)) {
223
+ fullyCovered = false;
224
+ break;
225
+ }
226
+ if (fullyCovered) {
223
227
  subsumed.push(entry);
224
228
  continue;
225
229
  }
226
230
  kept.push(entry);
227
- for (const d of days) covered.add(d);
231
+ for (let dayMs = startMs; dayMs <= endMs; dayMs += MS_PER_DAY) covered.add(dayMs);
228
232
  }
229
233
  return {
230
234
  kept,
@@ -14,8 +14,16 @@ interface DuckDBHandle {
14
14
  interface DuckDBFactory {
15
15
  getDuckDB: () => Promise<DuckDBHandle>;
16
16
  }
17
- declare function createDuckDBCodec(factory: DuckDBFactory): ParquetCodec;
18
- declare function createDuckDBExecutor(factory: DuckDBFactory): QueryExecutor;
17
+ interface DuckDBReadOptions {
18
+ /**
19
+ * Maximum non-URI parquet reads held in flight while registering DuckDB
20
+ * buffers. Defaults to 16; callers handling unusually large files can lower
21
+ * it to trade latency for peak JS memory.
22
+ */
23
+ bufferReadConcurrency?: number;
24
+ }
25
+ declare function createDuckDBCodec(factory: DuckDBFactory, options?: DuckDBReadOptions): ParquetCodec;
26
+ declare function createDuckDBExecutor(factory: DuckDBFactory, options?: DuckDBReadOptions): QueryExecutor;
19
27
  /**
20
28
  * Canonical "empty-file" SELECT clause for a table. Codecs that need to
21
29
  * emit a schema-correct empty Parquet can wrap this in:
@@ -8,11 +8,16 @@ import { sqlEscape } from "../sql-bind.mjs";
8
8
  import { encodeJsonBigintSafe } from "@gscdump/lakehouse";
9
9
  import { buildLogicalPlan } from "gscdump/query/plan";
10
10
  import { normalizeUrl } from "gscdump/normalize";
11
- const BUFFER_READ_BATCH_SIZE = 8;
12
- async function registerBufferedFiles(db, files, dataSource, registered, signal) {
13
- for (let i = 0; i < files.length; i += BUFFER_READ_BATCH_SIZE) {
11
+ const DEFAULT_BUFFER_READ_CONCURRENCY = 16;
12
+ const MAX_BUFFER_READ_CONCURRENCY = 64;
13
+ function bufferReadConcurrency(value, fileCount) {
14
+ return Math.max(1, Math.min(fileCount || 1, typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : DEFAULT_BUFFER_READ_CONCURRENCY, MAX_BUFFER_READ_CONCURRENCY));
15
+ }
16
+ async function registerBufferedFiles(db, files, dataSource, registered, signal, maxConcurrency) {
17
+ const batchSize = bufferReadConcurrency(maxConcurrency, files.length);
18
+ for (let i = 0; i < files.length; i += batchSize) {
14
19
  signal?.throwIfAborted();
15
- const batch = files.slice(i, i + BUFFER_READ_BATCH_SIZE);
20
+ const batch = files.slice(i, i + batchSize);
16
21
  const buffers = await Promise.all(batch.map((file) => dataSource.read(file.key, void 0, signal)));
17
22
  for (let j = 0; j < batch.length; j++) {
18
23
  signal?.throwIfAborted();
@@ -47,7 +52,7 @@ async function decodeBytes(db, bytes, table) {
47
52
  await db.dropFiles([name]);
48
53
  }
49
54
  }
50
- function createDuckDBCodec(factory) {
55
+ function createDuckDBCodec(factory, options = {}) {
51
56
  return {
52
57
  async writeRows(ctx, rows, key, dataSource) {
53
58
  const bytes = await encodeBytes(await factory.getDuckDB(), ctx.table, rows);
@@ -100,7 +105,7 @@ function createDuckDBCodec(factory) {
100
105
  };
101
106
  });
102
107
  try {
103
- await registerBufferedFiles(db, bufferedInputs, dataSource, registered);
108
+ await registerBufferedFiles(db, bufferedInputs, dataSource, registered, void 0, options.bufferReadConcurrency);
104
109
  const fileList = inNames.map((n) => `'${sqlEscape(n)}'`).join(", ");
105
110
  await db.query(`COPY (${dedupedMergeSql(ctx.table, fileList)}) TO '${sqlEscape(outName)}' (FORMAT PARQUET, COMPRESSION ZSTD)`);
106
111
  registered.push(outName);
@@ -137,7 +142,7 @@ function rewriteEmptyFileSets(sql, placeholders, defaultTable, placeholderTables
137
142
  }
138
143
  return out;
139
144
  }
140
- function createDuckDBExecutor(factory) {
145
+ function createDuckDBExecutor(factory, options = {}) {
141
146
  return { async execute({ sql, params, fileKeys, placeholderTables, dataSource, table, signal, profiler }) {
142
147
  signal?.throwIfAborted();
143
148
  const db = await factory.getDuckDB();
@@ -146,24 +151,24 @@ function createDuckDBExecutor(factory) {
146
151
  const totalFiles = Object.values(fileKeys).reduce((n, keys) => n + keys.length, 0);
147
152
  const endRegister = profiler?.start("files.register", { files: totalFiles });
148
153
  try {
149
- await Promise.all(Object.entries(fileKeys).map(async ([name, keys]) => {
154
+ const bufferedByName = /* @__PURE__ */ new Map();
155
+ for (const [name, keys] of Object.entries(fileKeys)) {
150
156
  const resolved = [];
151
- const buffered = [];
152
157
  for (let i = 0; i < keys.length; i++) {
153
158
  const key = keys[i];
154
159
  const uri = dataSource.uri?.(key);
155
160
  if (uri !== void 0) resolved.push(uri);
156
161
  else {
157
- buffered.push({
162
+ if (!bufferedByName.has(key)) bufferedByName.set(key, {
158
163
  key,
159
164
  name: key
160
165
  });
161
166
  resolved.push(key);
162
167
  }
163
168
  }
164
- await registerBufferedFiles(db, buffered, dataSource, registered, signal);
165
169
  placeholders[name] = resolved;
166
- }));
170
+ }
171
+ await registerBufferedFiles(db, [...bufferedByName.values()], dataSource, registered, signal, options.bufferReadConcurrency);
167
172
  endRegister?.({ buffered: registered.length });
168
173
  signal?.throwIfAborted();
169
174
  const finalSql = substituteNamedFiles(rewriteEmptyFileSets(sql, placeholders, table, placeholderTables), placeholders);
@@ -9,9 +9,6 @@ function isMissingKeyError(e) {
9
9
  return /\bnot found\b|\bENOENT\b|\bmissing key\b/i.test(message);
10
10
  }
11
11
  async function readOptional(ds, key, signal) {
12
- if (ds.head) {
13
- if (await ds.head(key) === void 0) return void 0;
14
- }
15
12
  return await ds.read(key, void 0, signal).catch((e) => {
16
13
  if (isMissingKeyError(e)) return void 0;
17
14
  throw e;
@@ -72,9 +69,6 @@ function buildQueryDimRecords(queries, deps) {
72
69
  return out;
73
70
  }
74
71
  function createQueryDimStore({ dataSource }) {
75
- async function exists(key, prefix) {
76
- return (await dataSource.list(prefix)).includes(key);
77
- }
78
72
  return {
79
73
  parquetKey: queryDimParquetKey,
80
74
  async write(ctx, records, builtAt) {
@@ -98,15 +92,14 @@ function createQueryDimStore({ dataSource }) {
98
92
  };
99
93
  },
100
94
  async loadMeta(ctx) {
101
- const key = queryDimMetaKey(ctx);
102
- if (!await exists(key, `${queryDimPrefix(ctx)}/`)) return null;
103
- const bytes = await dataSource.read(key);
95
+ const bytes = await readOptional(dataSource, queryDimMetaKey(ctx));
96
+ if (!bytes) return null;
104
97
  return JSON.parse(new TextDecoder().decode(bytes));
105
98
  },
106
99
  async loadRecords(ctx) {
107
- const key = queryDimParquetKey(ctx);
108
- if (!await exists(key, `${queryDimPrefix(ctx)}/`)) return [];
109
- return (await decodeParquetToRows(await dataSource.read(key))).map((r) => ({
100
+ const bytes = await readOptional(dataSource, queryDimParquetKey(ctx));
101
+ if (!bytes) return [];
102
+ return (await decodeParquetToRows(bytes)).map((r) => ({
110
103
  query: String(r.query),
111
104
  query_canonical: String(r.query_canonical),
112
105
  intent_code: Number(r.intent_code),
@@ -117,6 +110,21 @@ function createQueryDimStore({ dataSource }) {
117
110
  };
118
111
  }
119
112
  const YEAR_MONTH_RE = /^(\d{4})-(\d{2})-/;
113
+ const ENTITY_IO_CONCURRENCY = 8;
114
+ async function mapEntityIo(items, fn) {
115
+ if (items.length === 0) return [];
116
+ const results = Array.from({ length: items.length }, () => void 0);
117
+ let next = 0;
118
+ async function worker() {
119
+ while (true) {
120
+ const index = next++;
121
+ if (index >= items.length) return;
122
+ results[index] = await fn(items[index], index);
123
+ }
124
+ }
125
+ await Promise.all(Array.from({ length: Math.min(ENTITY_IO_CONCURRENCY, items.length) }, worker));
126
+ return results;
127
+ }
120
128
  function inspectionIndexKey(ctx) {
121
129
  return ctx.siteId ? `u_${ctx.userId}/${ctx.siteId}/entities/inspections/index.json` : `u_${ctx.userId}/entities/inspections/index.json`;
122
130
  }
@@ -311,33 +319,33 @@ function createInspectionStore(opts) {
311
319
  if (!byMonth.has(month)) byMonth.set(month, []);
312
320
  byMonth.get(month).push(r);
313
321
  }
314
- for (const [yearMonth, batch] of byMonth) {
322
+ await mapEntityIo([...byMonth].map(([yearMonth, batch]) => {
315
323
  const bytes = encodeJsonBigintSafe({
316
324
  version: 1,
317
325
  records: batch
318
326
  });
319
327
  if (bytes.byteLength > 5242880) throw new Error(`inspection history shard exceeds ${INSPECTION_HISTORY_MAX_BYTES} bytes (got ${bytes.byteLength}); split the batch`);
320
- await ds.write(inspectionHistoryShardKey(ctx, yearMonth, batchId), bytes);
321
- }
328
+ return {
329
+ key: inspectionHistoryShardKey(ctx, yearMonth, batchId),
330
+ bytes
331
+ };
332
+ }), (shard) => ds.write(shard.key, shard.bytes));
322
333
  },
323
334
  async loadHistory(ctx, yearMonth) {
324
335
  const keys = await ds.list(inspectionHistoryPrefix(ctx, yearMonth));
325
336
  if (keys.length === 0) return void 0;
326
- const out = [];
327
- for (const key of keys) {
328
- const bytes = await readOptional(ds, key);
329
- if (!bytes) continue;
330
- const shard = await Promise.resolve().then(() => JSON.parse(new TextDecoder().decode(bytes))).catch((err) => {
331
- console.warn("[inspection.loadHistory] failed to decode shard", {
332
- key,
333
- error: err.message
334
- });
335
- });
336
- if (shard?.records) out.push(...shard.records);
337
- }
338
337
  return {
339
338
  version: 1,
340
- records: out
339
+ records: (await mapEntityIo(keys, async (key) => {
340
+ const bytes = await readOptional(ds, key);
341
+ if (!bytes) return [];
342
+ return (await Promise.resolve().then(() => JSON.parse(new TextDecoder().decode(bytes))).catch((err) => {
343
+ console.warn("[inspection.loadHistory] failed to decode shard", {
344
+ key,
345
+ error: err.message
346
+ });
347
+ }))?.records ?? [];
348
+ })).flat()
341
349
  };
342
350
  },
343
351
  async materialize(ctx, rowIter) {
@@ -369,18 +377,19 @@ function createInspectionStore(opts) {
369
377
  bucket.push(r);
370
378
  byMonth.set(month, bucket);
371
379
  }
372
- const keys = [];
373
- for (const [month, batch] of byMonth) {
380
+ const files = [...byMonth].map(([month, batch]) => {
374
381
  const bytes = encodeRowsToParquetFlex(batch, {
375
382
  columns: INSPECTION_EVENT_COLUMNS,
376
383
  sortKey: ["urlHash"]
377
384
  });
378
- const key = inspectionEventKey(ctx, month, batchId);
379
- await ds.write(key, bytes);
380
- keys.push(key);
381
- }
385
+ return {
386
+ key: inspectionEventKey(ctx, month, batchId),
387
+ bytes
388
+ };
389
+ });
390
+ await mapEntityIo(files, (file) => ds.write(file.key, file.bytes));
382
391
  return {
383
- keys,
392
+ keys: files.map((file) => file.key),
384
393
  rowCount: rows.length
385
394
  };
386
395
  },
@@ -410,11 +419,18 @@ function createInspectionStore(opts) {
410
419
  for (const row of baseRows) consider(row);
411
420
  let eventsFolded = 0;
412
421
  const consumed = [];
413
- for (const key of eventKeys.sort()) {
422
+ const eventFiles = await mapEntityIo(eventKeys.sort(), async (key) => {
414
423
  const bytes = await readOptional(ds, key);
415
- if (!bytes) continue;
424
+ if (!bytes) return void 0;
425
+ return {
426
+ key,
427
+ rows: await decodeParquetToRows(bytes)
428
+ };
429
+ });
430
+ for (const file of eventFiles) {
431
+ if (!file) continue;
432
+ const { key, rows } = file;
416
433
  consumed.push(key);
417
- const rows = await decodeParquetToRows(bytes);
418
434
  for (const row of rows) {
419
435
  consider(row);
420
436
  eventsFolded++;
@@ -610,16 +626,18 @@ function createSitemapStore(opts) {
610
626
  records: {}
611
627
  };
612
628
  const stamp = now();
629
+ const historyDocs = /* @__PURE__ */ new Map();
613
630
  for (const r of records) {
614
631
  const h = hash(r.path);
615
632
  index.records[h] = r;
616
- await writeJson(sitemapHistoryKey(ctx, h, stamp), {
633
+ historyDocs.set(sitemapHistoryKey(ctx, h, stamp), {
617
634
  version: 1,
618
635
  path: r.path,
619
636
  capturedAt: r.capturedAt,
620
637
  record: r
621
638
  });
622
639
  }
640
+ await mapEntityIo([...historyDocs], ([key, doc]) => writeJson(key, doc));
623
641
  await writeJson(indexKey, index);
624
642
  },
625
643
  async loadIndex(ctx) {
@@ -702,9 +720,10 @@ function createSitemapStore(opts) {
702
720
  async *loadUrls(ctx, feedpath, opts) {
703
721
  const fpHash = hash(feedpath);
704
722
  const includeRemoved = opts?.includeRemoved ?? false;
705
- const indexBytes = await readOptional(ds, sitemapUrlsIndexKey(ctx, fpHash));
706
- const indexRows = indexBytes ? await decodeParquetToRows(indexBytes) : [];
707
- const deltaKeys = (await ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)).sort();
723
+ const [indexRows, listedDeltaKeys] = await Promise.all([readOptional(ds, sitemapUrlsIndexKey(ctx, fpHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)]);
724
+ const deltaKeys = listedDeltaKeys.filter((key) => {
725
+ return SITEMAP_URLS_DELTA_PREFIX_RE.exec(key)?.[2] === fpHash;
726
+ }).sort();
708
727
  const live = /* @__PURE__ */ new Map();
709
728
  const removedMap = /* @__PURE__ */ new Map();
710
729
  for (const row of indexRows) {
@@ -712,36 +731,34 @@ function createSitemapStore(opts) {
712
731
  if (rec.removedAt != null) removedMap.set(rec.urlHash, rec);
713
732
  else live.set(rec.urlHash, rec);
714
733
  }
715
- for (const key of deltaKeys) {
716
- const m = SITEMAP_URLS_DELTA_PREFIX_RE.exec(key);
717
- if (!m || m[2] !== fpHash) continue;
734
+ const deltas = await mapEntityIo(deltaKeys, async (key) => {
718
735
  const dBytes = await readOptional(ds, key);
719
- if (!dBytes) continue;
720
- const dRows = await decodeParquetToRows(dBytes);
721
- for (const r of dRows) {
722
- const op = String(r.op);
723
- const urlHash = String(r.url_hash);
724
- const at = Number(r.at);
725
- if (op === "added") {
726
- const prev = live.get(urlHash) ?? removedMap.get(urlHash);
727
- removedMap.delete(urlHash);
728
- live.set(urlHash, {
729
- feedpath,
730
- feedpathHash: fpHash,
731
- urlHash,
732
- loc: String(r.loc),
733
- lastmod: r.lastmod == null ? void 0 : String(r.lastmod),
734
- firstSeenAt: prev?.firstSeenAt ?? at,
735
- lastSeenAt: at
736
- });
737
- } else if (op === "removed") {
738
- const prev = live.get(urlHash);
739
- live.delete(urlHash);
740
- if (prev) removedMap.set(urlHash, {
741
- ...prev,
742
- removedAt: at
743
- });
744
- }
736
+ if (!dBytes) return [];
737
+ return decodeParquetToRows(dBytes);
738
+ });
739
+ for (const dRows of deltas) for (const r of dRows) {
740
+ const op = String(r.op);
741
+ const urlHash = String(r.url_hash);
742
+ const at = Number(r.at);
743
+ if (op === "added") {
744
+ const prev = live.get(urlHash) ?? removedMap.get(urlHash);
745
+ removedMap.delete(urlHash);
746
+ live.set(urlHash, {
747
+ feedpath,
748
+ feedpathHash: fpHash,
749
+ urlHash,
750
+ loc: String(r.loc),
751
+ lastmod: r.lastmod == null ? void 0 : String(r.lastmod),
752
+ firstSeenAt: prev?.firstSeenAt ?? at,
753
+ lastSeenAt: at
754
+ });
755
+ } else if (op === "removed") {
756
+ const prev = live.get(urlHash);
757
+ live.delete(urlHash);
758
+ if (prev) removedMap.set(urlHash, {
759
+ ...prev,
760
+ removedAt: at
761
+ });
745
762
  }
746
763
  }
747
764
  for (const rec of live.values()) yield rec;
@@ -796,8 +813,14 @@ function createSitemapStore(opts) {
796
813
  for (const [fpHash, feedDeltaKeys] of deltasByFeed) {
797
814
  if (compactedFeedpaths > 0 && (compactedFeedpaths >= maxFeedpaths || now() - startedAt >= deadlineMs)) break;
798
815
  const indexKey = sitemapUrlsIndexKey(ctx, fpHash);
799
- const indexBytes = await readOptional(ds, indexKey);
800
- const indexRows = indexBytes ? await decodeParquetToRows(indexBytes) : [];
816
+ const [indexRows, deltaFiles] = await Promise.all([readOptional(ds, indexKey).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), mapEntityIo(feedDeltaKeys.sort(), async (key) => {
817
+ const bytes = await readOptional(ds, key);
818
+ if (!bytes) return void 0;
819
+ return {
820
+ key,
821
+ rows: await decodeParquetToRows(bytes)
822
+ };
823
+ })]);
801
824
  const live = /* @__PURE__ */ new Map();
802
825
  const removed = /* @__PURE__ */ new Map();
803
826
  for (const row of indexRows) {
@@ -806,11 +829,10 @@ function createSitemapStore(opts) {
806
829
  else live.set(rec.urlHash, rec);
807
830
  }
808
831
  const consumed = [];
809
- for (const key of feedDeltaKeys.sort()) {
810
- const bytes = await readOptional(ds, key);
811
- if (!bytes) continue;
832
+ for (const file of deltaFiles) {
833
+ if (!file) continue;
834
+ const { key, rows } = file;
812
835
  consumed.push(key);
813
- const rows = await decodeParquetToRows(bytes);
814
836
  for (const r of rows) {
815
837
  const urlHash = String(r.url_hash);
816
838
  const at = Number(r.at);
@@ -856,12 +878,13 @@ function createSitemapStore(opts) {
856
878
  const at = atOpt ?? now();
857
879
  const liveHashes = new Set(liveFeedpaths.map((fp) => hash(fp)));
858
880
  const present = /* @__PURE__ */ new Set();
859
- for (const key of await ds.list(`${sitemapUrlsIndexPrefix(ctx)}/`)) {
881
+ const [indexKeys, deltaKeys] = await Promise.all([ds.list(`${sitemapUrlsIndexPrefix(ctx)}/`), ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)]);
882
+ for (const key of indexKeys) {
860
883
  const m = /\/by-feed\/([0-9a-f]+)\/index\.parquet$/.exec(key);
861
884
  if (m) present.add(m[1]);
862
885
  }
863
886
  const deltasByFeed = /* @__PURE__ */ new Map();
864
- for (const key of await ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)) {
887
+ for (const key of deltaKeys) {
865
888
  const m = SITEMAP_URLS_DELTA_PREFIX_RE.exec(key);
866
889
  if (!m) continue;
867
890
  present.add(m[2]);
@@ -874,8 +897,14 @@ function createSitemapStore(opts) {
874
897
  for (const fpHash of present) {
875
898
  if (liveHashes.has(fpHash)) continue;
876
899
  const indexKey = sitemapUrlsIndexKey(ctx, fpHash);
877
- const indexBytes = await readOptional(ds, indexKey);
878
- const indexRows = indexBytes ? await decodeParquetToRows(indexBytes) : [];
900
+ const [indexRows, deltaFiles] = await Promise.all([readOptional(ds, indexKey).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), mapEntityIo((deltasByFeed.get(fpHash) ?? []).sort(), async (key) => {
901
+ const bytes = await readOptional(ds, key);
902
+ if (!bytes) return void 0;
903
+ return {
904
+ key,
905
+ rows: await decodeParquetToRows(bytes)
906
+ };
907
+ })]);
879
908
  const live = /* @__PURE__ */ new Map();
880
909
  const removed = /* @__PURE__ */ new Map();
881
910
  for (const row of indexRows) {
@@ -884,11 +913,10 @@ function createSitemapStore(opts) {
884
913
  else live.set(r.urlHash, r);
885
914
  }
886
915
  const consumed = [];
887
- for (const key of (deltasByFeed.get(fpHash) ?? []).sort()) {
888
- const bytes = await readOptional(ds, key);
889
- if (!bytes) continue;
916
+ for (const file of deltaFiles) {
917
+ if (!file) continue;
918
+ const { key, rows } = file;
890
919
  consumed.push(key);
891
- const rows = await decodeParquetToRows(bytes);
892
920
  for (const r of rows) {
893
921
  const urlHash = String(r.url_hash);
894
922
  const dat = Number(r.at);
@@ -822,65 +822,67 @@ function buildExtrasQueries(state, options) {
822
822
  function mergeExtras(rows, extrasResults) {
823
823
  if (extrasResults.length === 0) return rows;
824
824
  const lookups = [];
825
+ const parseVariants = (raw) => {
826
+ if (raw.length === 0) return [];
827
+ const encoded = raw.split("||");
828
+ const variants = [];
829
+ for (const value of encoded) {
830
+ if (value.length === 0) continue;
831
+ const parts = value.split(":::");
832
+ variants.push({
833
+ query: parts[0],
834
+ clicks: Number(parts[1] || 0),
835
+ impressions: Number(parts[2] || 0),
836
+ position: Number(parts[3] || 0)
837
+ });
838
+ }
839
+ return variants;
840
+ };
825
841
  for (const { key, results } of extrasResults) {
826
842
  if (key === "canonicalExtras") {
827
- const variantCountMap = /* @__PURE__ */ new Map();
828
- const variantsMap = /* @__PURE__ */ new Map();
829
- const canonicalNameMap = /* @__PURE__ */ new Map();
843
+ const map = /* @__PURE__ */ new Map();
830
844
  for (const r of results) {
831
845
  const jk = String(r.joinKey);
832
- variantCountMap.set(jk, r.variantCount);
833
- canonicalNameMap.set(jk, r.canonicalName);
834
846
  const raw = r.variants;
835
- variantsMap.set(jk, typeof raw === "string" ? raw.split("||").filter(Boolean).map((v) => {
836
- const parts = v.split(":::");
837
- return {
838
- query: parts[0],
839
- clicks: Number(parts[1] || 0),
840
- impressions: Number(parts[2] || 0),
841
- position: Number(parts[3] || 0)
842
- };
843
- }) : []);
847
+ map.set(jk, {
848
+ variantCount: r.variantCount,
849
+ variants: typeof raw === "string" ? parseVariants(raw) : [],
850
+ canonicalName: r.canonicalName
851
+ });
844
852
  }
845
853
  lookups.push({
846
- key: "variantCount",
847
- map: variantCountMap
848
- });
849
- lookups.push({
850
- key: "variants",
851
- map: variantsMap
852
- });
853
- lookups.push({
854
- key: "canonicalName",
855
- map: canonicalNameMap
854
+ kind: "canonical",
855
+ map
856
856
  });
857
857
  continue;
858
858
  }
859
- const filtered = results.filter((r) => r.rn === void 0 || r.rn === 1);
860
859
  const map = /* @__PURE__ */ new Map();
861
- for (const r of filtered) {
860
+ for (const r of results) {
861
+ if (r.rn !== void 0 && r.rn !== 1) continue;
862
862
  let val = r[key];
863
- if (key === "variants" && typeof val === "string") val = val.split("||").filter(Boolean).map((v) => {
864
- const parts = v.split(":::");
865
- return {
866
- query: parts[0],
867
- clicks: Number(parts[1] || 0),
868
- impressions: Number(parts[2] || 0),
869
- position: Number(parts[3] || 0)
870
- };
871
- });
863
+ if (key === "variants" && typeof val === "string") val = parseVariants(val);
872
864
  map.set(String(r.joinKey), val);
873
865
  }
874
866
  lookups.push({
867
+ kind: "generic",
875
868
  key,
876
869
  map
877
870
  });
878
871
  }
879
872
  return rows.map((row) => {
880
873
  const enriched = { ...row };
881
- for (const { key, map } of lookups) {
882
- let joinValue;
883
- if (key === "variantCount" || key === "variants" || key === "canonicalName") joinValue = String(row.queryCanonical ?? row.query_canonical ?? "");
874
+ for (const lookup of lookups) {
875
+ if (lookup.kind === "canonical") {
876
+ const joinValue = String(row.queryCanonical ?? row.query_canonical ?? "");
877
+ const extra = joinValue ? lookup.map.get(joinValue) : void 0;
878
+ enriched.variantCount = extra?.variantCount ?? null;
879
+ enriched.variants = extra?.variants ?? [];
880
+ enriched.canonicalName = extra?.canonicalName ?? null;
881
+ if (enriched.canonicalName) enriched.queryCanonical = enriched.canonicalName;
882
+ continue;
883
+ }
884
+ const { key, map } = lookup;
885
+ const joinValue = key === "variantCount" || key === "variants" || key === "canonicalName" ? String(row.queryCanonical ?? row.query_canonical ?? "") : void 0;
884
886
  enriched[key] = (joinValue && map.get(joinValue)) ?? (key === "variants" ? [] : null);
885
887
  if (key === "canonicalName" && enriched[key]) enriched.queryCanonical = enriched[key];
886
888
  }
@@ -955,7 +957,10 @@ function matchesMetricFilter(row, filter) {
955
957
  }
956
958
  }
957
959
  function matchesTopLevelPage(row) {
958
- return (normalizeUrl(dimensionValue(row, "page")).match(/\//g)?.length ?? 0) <= 1;
960
+ const path = normalizeUrl(dimensionValue(row, "page"));
961
+ let slashes = 0;
962
+ for (let index = 0; index < path.length; index++) if (path.charCodeAt(index) === 47 && ++slashes > 1) return false;
963
+ return true;
959
964
  }
960
965
  var QuerySourceCoverageError = class extends Error {
961
966
  fallback;
@@ -2,31 +2,71 @@ import { manifestEntryKey, matchesManifestEntryFilter, matchesSyncStateFilter, m
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { Buffer } from "node:buffer";
4
4
  import { randomBytes } from "node:crypto";
5
- import { mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
5
+ import { mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
6
6
  import { lock } from "proper-lockfile";
7
7
  function createFilesystemDataSource(opts) {
8
8
  const root = resolve(opts.rootDir);
9
+ const readyDirectories = /* @__PURE__ */ new Map();
9
10
  function pathFor(key) {
10
11
  const resolved = resolve(root, key);
11
12
  if (!resolved.startsWith(`${root}/`) && resolved !== root) throw new Error(`path escapes root: ${key}`);
12
13
  return resolved;
13
14
  }
15
+ async function ensureDirectory(path) {
16
+ let pending = readyDirectories.get(path);
17
+ if (!pending) {
18
+ pending = mkdir(path, { recursive: true }).then(() => void 0);
19
+ readyDirectories.set(path, pending);
20
+ pending.catch(() => readyDirectories.delete(path));
21
+ }
22
+ await pending;
23
+ }
14
24
  return {
15
25
  async read(key, range, signal) {
16
- const bytes = await readFile(pathFor(key), { signal });
17
- if (!range) return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
18
- const sliced = bytes.subarray(range.offset, range.offset + range.length);
19
- return new Uint8Array(sliced.buffer, sliced.byteOffset, sliced.byteLength);
26
+ const path = pathFor(key);
27
+ if (!range) {
28
+ const bytes = await readFile(path, { signal });
29
+ return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30
+ }
31
+ signal?.throwIfAborted();
32
+ const handle = await open(path, "r");
33
+ try {
34
+ const bytes = Buffer.allocUnsafe(range.length);
35
+ let totalRead = 0;
36
+ while (totalRead < range.length) {
37
+ signal?.throwIfAborted();
38
+ const { bytesRead } = await handle.read(bytes, totalRead, range.length - totalRead, range.offset + totalRead);
39
+ if (bytesRead === 0) break;
40
+ totalRead += bytesRead;
41
+ }
42
+ return new Uint8Array(bytes.buffer, bytes.byteOffset, totalRead);
43
+ } finally {
44
+ await handle.close();
45
+ }
20
46
  },
21
47
  async write(key, bytes) {
22
48
  const path = pathFor(key);
23
- await mkdir(dirname(path), { recursive: true });
24
- await writeFile(path, Buffer.from(bytes));
49
+ const dir = dirname(path);
50
+ await ensureDirectory(dir);
51
+ try {
52
+ await writeFile(path, bytes);
53
+ } catch (error) {
54
+ if (error.code !== "ENOENT") throw error;
55
+ readyDirectories.delete(dir);
56
+ await ensureDirectory(dir);
57
+ await writeFile(path, bytes);
58
+ }
25
59
  },
26
60
  async delete(keys) {
27
- await Promise.all(keys.map(async (k) => {
28
- await rm(pathFor(k), { force: true });
29
- }));
61
+ let next = 0;
62
+ async function worker() {
63
+ while (true) {
64
+ const index = next++;
65
+ if (index >= keys.length) return;
66
+ await rm(pathFor(keys[index]), { force: true });
67
+ }
68
+ }
69
+ await Promise.all(Array.from({ length: Math.min(32, keys.length) }, worker));
30
70
  },
31
71
  async list(prefix) {
32
72
  const full = pathFor(prefix);
@@ -61,14 +101,23 @@ async function* walkStream(dir) {
61
101
  }
62
102
  }
63
103
  async function walk(dir, out) {
64
- const entries = await readdir(dir, { withFileTypes: true }).catch((err) => {
65
- if (err.code === "ENOENT") return [];
66
- throw err;
67
- });
68
- for (const entry of entries) {
69
- const p = join(dir, String(entry.name));
70
- if (entry.isDirectory()) await walk(p, out);
71
- else out.push(p);
104
+ const pending = [dir];
105
+ let next = 0;
106
+ while (next < pending.length) {
107
+ const batch = pending.slice(next, next + 32);
108
+ next += batch.length;
109
+ const listings = await Promise.all(batch.map(async (current) => ({
110
+ current,
111
+ entries: await readdir(current, { withFileTypes: true }).catch((err) => {
112
+ if (err.code === "ENOENT") return [];
113
+ throw err;
114
+ })
115
+ })));
116
+ for (const { current, entries } of listings) for (const entry of entries) {
117
+ const p = join(current, String(entry.name));
118
+ if (entry.isDirectory()) pending.push(p);
119
+ else out.push(p);
120
+ }
72
121
  }
73
122
  }
74
123
  function lockFileFor(locksDir, scope) {
@@ -77,6 +126,16 @@ function lockFileFor(locksDir, scope) {
77
126
  function createFilesystemManifestStore(opts) {
78
127
  const manifestPath = resolve(opts.path);
79
128
  const locksDir = join(dirname(manifestPath), "locks");
129
+ const readyDirectories = /* @__PURE__ */ new Map();
130
+ async function ensureDirectory(path) {
131
+ let pending = readyDirectories.get(path);
132
+ if (!pending) {
133
+ pending = mkdir(path, { recursive: true }).then(() => void 0);
134
+ readyDirectories.set(path, pending);
135
+ pending.catch(() => readyDirectories.delete(path));
136
+ }
137
+ await pending;
138
+ }
80
139
  async function load() {
81
140
  const content = await readFile(manifestPath, "utf8").catch((err) => {
82
141
  if (err.code === "ENOENT") return null;
@@ -91,9 +150,16 @@ function createFilesystemManifestStore(opts) {
91
150
  return parsed;
92
151
  }
93
152
  async function save(data) {
94
- await mkdir(dirname(manifestPath), { recursive: true });
153
+ const manifestDir = dirname(manifestPath);
154
+ await ensureDirectory(manifestDir);
95
155
  const tmp = `${manifestPath}.${randomBytes(6).toString("hex")}.tmp`;
96
- await writeFile(tmp, JSON.stringify(data), "utf8");
156
+ const content = JSON.stringify(data);
157
+ await writeFile(tmp, content, "utf8").catch(async (error) => {
158
+ if (error.code !== "ENOENT") throw error;
159
+ readyDirectories.delete(manifestDir);
160
+ await ensureDirectory(manifestDir);
161
+ await writeFile(tmp, content, "utf8");
162
+ });
97
163
  await rename(tmp, manifestPath).catch(async (err) => {
98
164
  try {
99
165
  await unlink(tmp);
@@ -175,9 +241,14 @@ function createFilesystemManifestStore(opts) {
175
241
  });
176
242
  },
177
243
  async withLock(scope, fn) {
178
- await mkdir(locksDir, { recursive: true });
244
+ await ensureDirectory(locksDir);
179
245
  const path = lockFileFor(locksDir, scope);
180
- await writeFile(path, "", { flag: "a" });
246
+ await writeFile(path, "", { flag: "a" }).catch(async (error) => {
247
+ if (error.code !== "ENOENT") throw error;
248
+ readyDirectories.delete(locksDir);
249
+ await ensureDirectory(locksDir);
250
+ await writeFile(path, "", { flag: "a" });
251
+ });
181
252
  const release = await lock(path, {
182
253
  realpath: false,
183
254
  stale: 3e4,
@@ -244,24 +315,22 @@ async function filesystemStats(rootDir) {
244
315
  const keys = [];
245
316
  await walkForStats(resolve(rootDir), keys);
246
317
  let bytes = 0;
247
- for (const k of keys) {
248
- const s = await stat(k);
249
- bytes += s.size;
318
+ let next = 0;
319
+ async function worker() {
320
+ while (true) {
321
+ const index = next++;
322
+ if (index >= keys.length) return;
323
+ const s = await stat(keys[index]);
324
+ bytes += s.size;
325
+ }
250
326
  }
327
+ await Promise.all(Array.from({ length: Math.min(32, keys.length) }, worker));
251
328
  return {
252
329
  files: keys.length,
253
330
  bytes
254
331
  };
255
332
  }
256
333
  async function walkForStats(dir, out) {
257
- const entries = await readdir(dir, { withFileTypes: true }).catch((err) => {
258
- if (err.code === "ENOENT") return [];
259
- throw err;
260
- });
261
- for (const entry of entries) {
262
- const p = join(dir, String(entry.name));
263
- if (entry.isDirectory()) await walkForStats(p, out);
264
- else out.push(p);
265
- }
334
+ await walk(dir, out);
266
335
  }
267
336
  export { createFilesystemDataSource, createFilesystemManifestStore, filesystemStats };
@@ -45,6 +45,11 @@ interface HyparquetCodecOptions {
45
45
  * hyparquet to avoid WASM linear-memory growth. Defaults to hyparquet.
46
46
  */
47
47
  readRows?: (ctx: CodecCtx, key: string, dataSource: DataSource) => Promise<Row[]>;
48
+ /**
49
+ * Maximum input-file reads held in flight during compaction. Defaults to 8;
50
+ * lower it for unusually large files to trade latency for peak JS memory.
51
+ */
52
+ compactionReadConcurrency?: number;
48
53
  }
49
54
  declare function createHyparquetCodec(options?: HyparquetCodecOptions): ParquetCodec;
50
55
  export { DecodeParquetOptions, EncodeFlexOptions, HyparquetCodecOptions, createHyparquetCodec, decodeParquetToRows, encodeRowsToParquet, encodeRowsToParquetFlex };
@@ -2,6 +2,8 @@ import { SCHEMAS, TABLE_METADATA } from "../_chunks/schema.mjs";
2
2
  import { parquetReadObjects } from "../_chunks/libs/hyparquet.mjs";
3
3
  import { ByteWriter, parquetWriteRows } from "../_chunks/libs/hyparquet-writer.mjs";
4
4
  const ROW_GROUP_SIZE = 25e3;
5
+ const DEFAULT_COMPACTION_READ_CONCURRENCY = 8;
6
+ const MAX_COMPACTION_READ_CONCURRENCY = 32;
5
7
  function basicTypeFor(colType) {
6
8
  if (colType === "VARCHAR") return "STRING";
7
9
  if (colType === "BIGINT") return "INT64";
@@ -120,7 +122,13 @@ function sortRowsByClusterKey(table, rows) {
120
122
  return copy;
121
123
  }
122
124
  function naturalKeyFor(table, row) {
123
- return TABLE_METADATA[table].sortKey.map((col) => `${row[col] ?? ""}`).join("\0");
125
+ const key = TABLE_METADATA[table].sortKey;
126
+ let value = "";
127
+ for (let index = 0; index < key.length; index++) {
128
+ if (index > 0) value += "\0";
129
+ value += `${row[key[index]] ?? ""}`;
130
+ }
131
+ return value;
124
132
  }
125
133
  function encodeOrderedRows(rows, columns, rowGroupSize) {
126
134
  const schema = buildWriteSchema(columns);
@@ -204,6 +212,11 @@ function normalizeDecodedDates(rows) {
204
212
  return rows;
205
213
  }
206
214
  function createHyparquetCodec(options = {}) {
215
+ const readRows = options.readRows ?? (async (_ctx, key, dataSource) => {
216
+ return decodeParquetToRows(await dataSource.read(key));
217
+ });
218
+ const requestedConcurrency = options.compactionReadConcurrency;
219
+ const compactionReadConcurrency = typeof requestedConcurrency === "number" && Number.isFinite(requestedConcurrency) ? Math.max(1, Math.min(MAX_COMPACTION_READ_CONCURRENCY, Math.floor(requestedConcurrency))) : DEFAULT_COMPACTION_READ_CONCURRENCY;
207
220
  return {
208
221
  async writeRows(ctx, rows, key, dataSource) {
209
222
  const bytes = encodeRowsToParquet(ctx.table, rows);
@@ -213,9 +226,7 @@ function createHyparquetCodec(options = {}) {
213
226
  rowCount: rows.length
214
227
  };
215
228
  },
216
- readRows: options.readRows ?? (async (_ctx, key, dataSource) => {
217
- return decodeParquetToRows(await dataSource.read(key));
218
- }),
229
+ readRows,
219
230
  async compactRows(ctx, inputKeys, outputKey, dataSource) {
220
231
  if (inputKeys.length === 0) {
221
232
  const bytes = encodeRowsToParquet(ctx.table, []);
@@ -226,9 +237,13 @@ function createHyparquetCodec(options = {}) {
226
237
  };
227
238
  }
228
239
  const byNaturalKey = /* @__PURE__ */ new Map();
229
- for (const key of inputKeys) {
230
- const rows = await decodeParquetToRows(await dataSource.read(key));
231
- for (let i = 0; i < rows.length; i++) byNaturalKey.set(naturalKeyFor(ctx.table, rows[i]), rows[i]);
240
+ for (let offset = 0; offset < inputKeys.length; offset += compactionReadConcurrency) {
241
+ const batch = inputKeys.slice(offset, offset + compactionReadConcurrency);
242
+ const inputs = await Promise.all(batch.map((key) => dataSource.read(key)));
243
+ for (const input of inputs) {
244
+ const rows = await decodeParquetToRows(input);
245
+ for (let i = 0; i < rows.length; i++) byNaturalKey.set(naturalKeyFor(ctx.table, rows[i]), rows[i]);
246
+ }
232
247
  }
233
248
  const rows = [...byNaturalKey.values()];
234
249
  const bytes = encodeRowsToParquet(ctx.table, rows);
@@ -6,6 +6,7 @@ const SHARD_RE = /^u_[^/]+\/manifest\/(?<siteId>[^/]+)\/(?<table>[^/]+)\/HEAD$/;
6
6
  const CAS_BACKOFF_BASE_MS = 5;
7
7
  const CAS_BACKOFF_CAP_MS = 250;
8
8
  const SHARD_IO_CONCURRENCY = 8;
9
+ const R2_DELETE_BATCH_SIZE = 1e3;
9
10
  async function casBackoff(attempt) {
10
11
  const ceil = Math.min(CAS_BACKOFF_CAP_MS, CAS_BACKOFF_BASE_MS * 2 ** attempt);
11
12
  await new Promise((resolve) => setTimeout(resolve, Math.random() * ceil));
@@ -331,15 +332,8 @@ function createR2ManifestStore(opts) {
331
332
  },
332
333
  async purgeTenant(filter) {
333
334
  if (filter.userId !== userId) throw new Error(`purgeTenant: store is scoped to userId=${userId}, got ${filter.userId}`);
334
- const shards = await shardsForFilter({ siteId: filter.siteId });
335
- let entriesRemoved = 0;
336
- let watermarksRemoved = 0;
337
- let syncStatesRemoved = 0;
338
- for (const { siteId, table } of shards) {
335
+ const purged = await mapWithConcurrency(await shardsForFilter({ siteId: filter.siteId }), SHARD_IO_CONCURRENCY, async ({ siteId, table }) => {
339
336
  const { snapshot } = await readShard(siteId, table);
340
- entriesRemoved += snapshot.entries.length;
341
- watermarksRemoved += snapshot.watermarks.length;
342
- syncStatesRemoved += snapshot.syncStates.length;
343
337
  const prefix = shardPrefix(userId, siteId, table);
344
338
  const keys = [];
345
339
  let cursor;
@@ -352,12 +346,17 @@ function createR2ManifestStore(opts) {
352
346
  for (const obj of res.objects) keys.push(obj.key);
353
347
  cursor = res.truncated ? res.cursor : void 0;
354
348
  } while (cursor);
355
- if (keys.length > 0) await bucket.delete(keys);
356
- }
349
+ for (let offset = 0; offset < keys.length; offset += R2_DELETE_BATCH_SIZE) await bucket.delete(keys.slice(offset, offset + R2_DELETE_BATCH_SIZE));
350
+ return {
351
+ entriesRemoved: snapshot.entries.length,
352
+ watermarksRemoved: snapshot.watermarks.length,
353
+ syncStatesRemoved: snapshot.syncStates.length
354
+ };
355
+ });
357
356
  return {
358
- entriesRemoved,
359
- watermarksRemoved,
360
- syncStatesRemoved
357
+ entriesRemoved: purged.reduce((sum, result) => sum + result.entriesRemoved, 0),
358
+ watermarksRemoved: purged.reduce((sum, result) => sum + result.watermarksRemoved, 0),
359
+ syncStatesRemoved: purged.reduce((sum, result) => sum + result.syncStatesRemoved, 0)
361
360
  };
362
361
  }
363
362
  };
@@ -1,5 +1,7 @@
1
1
  const BUCKET_NAME_RE = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
2
2
  const KEY_RE = /^[\w./=-]+$/;
3
+ const DELETE_CHUNK_SIZE = 1e3;
4
+ const DELETE_CONCURRENCY = 4;
3
5
  function assertKey(key) {
4
6
  if (!KEY_RE.test(key)) throw new Error(`createR2DataSource: refusing unsafe key ${JSON.stringify(key)}`);
5
7
  }
@@ -22,8 +24,17 @@ function createR2DataSource(options) {
22
24
  },
23
25
  async delete(keys) {
24
26
  if (keys.length === 0) return;
25
- const CHUNK = 1e3;
26
- for (let i = 0; i < keys.length; i += CHUNK) await bucket.delete(keys.slice(i, i + CHUNK));
27
+ let next = 0;
28
+ async function worker() {
29
+ while (true) {
30
+ const offset = next;
31
+ next += DELETE_CHUNK_SIZE;
32
+ if (offset >= keys.length) return;
33
+ await bucket.delete(keys.slice(offset, offset + DELETE_CHUNK_SIZE));
34
+ }
35
+ }
36
+ const chunkCount = Math.ceil(keys.length / DELETE_CHUNK_SIZE);
37
+ await Promise.all(Array.from({ length: Math.min(DELETE_CONCURRENCY, chunkCount) }, worker));
27
38
  },
28
39
  async list(prefix) {
29
40
  const out = [];
@@ -137,8 +137,12 @@ function dedupeByIdentity(table, records) {
137
137
  const key = gscDataset(table, "int").tableSpec.identityColumns;
138
138
  const seen = /* @__PURE__ */ new Map();
139
139
  for (const rec of records) {
140
- const k = key.map((col) => `${rec[col] ?? ""}`).join("\0");
141
- seen.set(k, rec);
140
+ let identity = "";
141
+ for (let index = 0; index < key.length; index++) {
142
+ if (index > 0) identity += "\0";
143
+ identity += `${rec[key[index]] ?? ""}`;
144
+ }
145
+ seen.set(identity, rec);
142
146
  }
143
147
  return seen.size === records.length ? records : [...seen.values()];
144
148
  }
package/dist/rollups.mjs CHANGED
@@ -195,26 +195,42 @@ function planRollupWindows(parts, clampRange, maxWindowDays) {
195
195
  });
196
196
  }
197
197
  if (spans.length === 0) return [];
198
- let rangeStartMs = Math.min(...spans.map((s) => s.startMs));
199
- let rangeEndMs = Math.max(...spans.map((s) => s.endMs));
198
+ let rangeStartMs = Number.POSITIVE_INFINITY;
199
+ let rangeEndMs = Number.NEGATIVE_INFINITY;
200
+ let totalBytes = 0;
201
+ for (const span of spans) {
202
+ if (span.startMs < rangeStartMs) rangeStartMs = span.startMs;
203
+ if (span.endMs > rangeEndMs) rangeEndMs = span.endMs;
204
+ totalBytes += span.bytes;
205
+ }
200
206
  if (clampStartMs !== void 0) rangeStartMs = Math.max(rangeStartMs, clampStartMs);
201
207
  if (clampEndMs !== void 0) rangeEndMs = Math.min(rangeEndMs, clampEndMs);
202
- const totalBytes = spans.reduce((a, s) => a + s.bytes, 0);
203
208
  const spanDays = Math.floor((rangeEndMs - rangeStartMs) / MS_PER_DAY) + 1;
204
209
  const bytesPerDay = Math.max(1, totalBytes / spanDays);
205
210
  const byteWindowDays = clamp(Math.floor(WINDOW_BYTE_BUDGET / bytesPerDay), 7, 400);
206
211
  const windowDays = maxWindowDays != null ? Math.max(1, Math.min(byteWindowDays, maxWindowDays)) : byteWindowDays;
212
+ if (!Number.isFinite(windowDays) || !Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs) || rangeEndMs < rangeStartMs) return [];
213
+ const windowWidthMs = windowDays * MS_PER_DAY;
214
+ const windowCount = Math.floor((rangeEndMs - rangeStartMs) / windowWidthMs) + 1;
215
+ const partitionsByWindow = Array.from({ length: windowCount }, () => []);
216
+ for (const span of spans) {
217
+ const overlapStartMs = Math.max(span.startMs, rangeStartMs);
218
+ const overlapEndMs = Math.min(span.endMs, rangeEndMs);
219
+ if (overlapStartMs > overlapEndMs) continue;
220
+ const firstWindow = Math.max(0, Math.ceil((overlapStartMs - rangeStartMs - (windowDays - 1) * MS_PER_DAY) / windowWidthMs));
221
+ const lastWindow = Math.min(windowCount - 1, Math.floor((overlapEndMs - rangeStartMs) / windowWidthMs));
222
+ for (let index = firstWindow; index <= lastWindow; index++) partitionsByWindow[index].push(span.partition);
223
+ }
207
224
  const windows = [];
208
- let cursorMs = rangeStartMs;
209
- while (cursorMs <= rangeEndMs) {
225
+ for (let index = 0; index < windowCount; index++) {
226
+ const cursorMs = rangeStartMs + index * windowWidthMs;
210
227
  const windowEndMs = Math.min(cursorMs + (windowDays - 1) * MS_PER_DAY, rangeEndMs);
211
- const partitions = spans.filter((s) => s.endMs >= cursorMs && s.startMs <= windowEndMs).map((s) => s.partition);
228
+ const partitions = partitionsByWindow[index];
212
229
  if (partitions.length > 0) windows.push({
213
230
  start: isoDate(cursorMs),
214
231
  end: isoDate(windowEndMs),
215
232
  partitions
216
233
  });
217
- cursorMs = windowEndMs + MS_PER_DAY;
218
234
  }
219
235
  return windows;
220
236
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/engine",
3
3
  "type": "module",
4
- "version": "0.38.1",
4
+ "version": "0.39.0",
5
5
  "description": "Append-only Parquet/DuckDB storage engine + planner + adapters for the gscdump pipeline. Node + edge runtimes; opt-in heavy peers.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -181,9 +181,9 @@
181
181
  "dependencies": {
182
182
  "drizzle-orm": "1.0.0-rc.3",
183
183
  "proper-lockfile": "^4.1.2",
184
- "@gscdump/contracts": "0.38.1",
185
- "@gscdump/lakehouse": "0.38.1",
186
- "gscdump": "0.38.1"
184
+ "@gscdump/contracts": "0.39.0",
185
+ "@gscdump/lakehouse": "0.39.0",
186
+ "gscdump": "0.39.0"
187
187
  },
188
188
  "devDependencies": {
189
189
  "@duckdb/duckdb-wasm": "^1.32.0",