@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.
- package/dist/_chunks/compaction.mjs +14 -10
- package/dist/_chunks/duckdb.d.mts +10 -2
- package/dist/_chunks/engine.mjs +17 -12
- package/dist/_chunks/entities.mjs +113 -85
- package/dist/_chunks/resolver.mjs +44 -39
- package/dist/adapters/filesystem.mjs +103 -34
- package/dist/adapters/hyparquet.d.mts +5 -0
- package/dist/adapters/hyparquet.mjs +22 -7
- package/dist/adapters/r2-manifest.mjs +12 -13
- package/dist/adapters/r2.mjs +13 -2
- package/dist/iceberg/index.mjs +6 -2
- package/dist/rollups.mjs +23 -7
- package/package.json +4 -4
|
@@ -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
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
18
|
-
|
|
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:
|
package/dist/_chunks/engine.mjs
CHANGED
|
@@ -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
|
|
12
|
-
|
|
13
|
-
|
|
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 +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
102
|
-
if (!
|
|
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
|
|
108
|
-
if (!
|
|
109
|
-
return (await decodeParquetToRows(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
422
|
+
const eventFiles = await mapEntityIo(eventKeys.sort(), async (key) => {
|
|
414
423
|
const bytes = await readOptional(ds, key);
|
|
415
|
-
if (!bytes)
|
|
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
|
-
|
|
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
|
|
706
|
-
const
|
|
707
|
-
|
|
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
|
-
|
|
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)
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
|
800
|
-
|
|
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
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
878
|
-
|
|
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
|
|
888
|
-
|
|
889
|
-
|
|
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
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
847
|
-
map
|
|
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
|
|
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
|
|
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
|
|
882
|
-
|
|
883
|
-
|
|
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
|
-
|
|
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
|
|
17
|
-
if (!range)
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
await
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
153
|
+
const manifestDir = dirname(manifestPath);
|
|
154
|
+
await ensureDirectory(manifestDir);
|
|
95
155
|
const tmp = `${manifestPath}.${randomBytes(6).toString("hex")}.tmp`;
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
230
|
-
const
|
|
231
|
-
|
|
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
|
|
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
|
-
|
|
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
|
};
|
package/dist/adapters/r2.mjs
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
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 = [];
|
package/dist/iceberg/index.mjs
CHANGED
|
@@ -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
|
-
|
|
141
|
-
|
|
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 =
|
|
199
|
-
let rangeEndMs =
|
|
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
|
|
209
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
185
|
-
"@gscdump/lakehouse": "0.
|
|
186
|
-
"gscdump": "0.
|
|
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",
|