@gscdump/engine 0.37.3 → 0.37.4
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/coerce.mjs +7 -3
- package/dist/_chunks/compaction.mjs +1 -1
- package/dist/_chunks/engine.mjs +14 -4
- package/dist/_chunks/entities.mjs +20 -2
- package/dist/_chunks/layout.mjs +1 -1
- package/dist/adapters/duckdb-node.mjs +8 -3
- package/dist/adapters/filesystem.mjs +6 -2
- package/dist/index.mjs +35 -13
- package/dist/ingest.mjs +45 -18
- package/dist/period/index.mjs +3 -3
- package/dist/rollups.d.mts +0 -20
- package/dist/rollups.mjs +82 -29
- package/dist/sink-node.mjs +33 -1
- package/package.json +5 -4
package/dist/_chunks/coerce.mjs
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { coerceBigIntToNumber } from "@gscdump/lakehouse";
|
|
2
2
|
function coerceRow(row) {
|
|
3
3
|
let mutated = null;
|
|
4
|
-
for (const
|
|
5
|
-
if (!
|
|
6
|
-
|
|
4
|
+
for (const k in row) {
|
|
5
|
+
if (!Object.hasOwn(row, k)) continue;
|
|
6
|
+
const v = row[k];
|
|
7
|
+
if (typeof v === "bigint") {
|
|
8
|
+
if (!mutated) mutated = { ...row };
|
|
9
|
+
mutated[k] = coerceBigIntToNumber(v);
|
|
10
|
+
}
|
|
7
11
|
}
|
|
8
12
|
return mutated ?? row;
|
|
9
13
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { dayPartition, inferSearchType, mondayOfWeek, monthPartition, objectKey, quarterOfMonth, quarterPartition, weekPartition } from "./layout.mjs";
|
|
2
2
|
import { currentSchemaVersion } from "./schema.mjs";
|
|
3
|
-
import { MS_PER_DAY } from "gscdump";
|
|
3
|
+
import { MS_PER_DAY } from "gscdump/dates";
|
|
4
4
|
const DAILY_PARTITION_RE = /^daily\/(\d{4}-\d{2}-\d{2})$/;
|
|
5
5
|
const WEEKLY_PARTITION_RE = /^weekly\/(\d{4}-\d{2}-\d{2})$/;
|
|
6
6
|
const MONTHLY_PARTITION_RE = /^monthly\/(\d{4}-\d{2})$/;
|
package/dist/_chunks/engine.mjs
CHANGED
|
@@ -414,8 +414,13 @@ function createStorageEngine(opts) {
|
|
|
414
414
|
if (probed) bytes = probed.bytes;
|
|
415
415
|
}
|
|
416
416
|
if (bytes > 104857600) {
|
|
417
|
-
|
|
418
|
-
|
|
417
|
+
const sizeError = /* @__PURE__ */ new Error(`writeDay payload ${bytes} bytes exceeds ${MAX_DAY_BYTES} hard ceiling (table=${ctx.table}, key=${key})`);
|
|
418
|
+
try {
|
|
419
|
+
await dataSource.delete([key]);
|
|
420
|
+
} catch (deleteError) {
|
|
421
|
+
throw new AggregateError([sizeError, deleteError], `${sizeError.message}; failed to delete oversized object`);
|
|
422
|
+
}
|
|
423
|
+
throw sizeError;
|
|
419
424
|
}
|
|
420
425
|
const entry = {
|
|
421
426
|
userId: ctx.userId,
|
|
@@ -482,8 +487,13 @@ function createStorageEngine(opts) {
|
|
|
482
487
|
if (probed) bytes = probed.bytes;
|
|
483
488
|
}
|
|
484
489
|
if (bytes > 104857600) {
|
|
485
|
-
|
|
486
|
-
|
|
490
|
+
const sizeError = /* @__PURE__ */ new Error(`writeHour payload ${bytes} bytes exceeds ${MAX_DAY_BYTES} hard ceiling (table=${ctx.table}, key=${key})`);
|
|
491
|
+
try {
|
|
492
|
+
await dataSource.delete([key]);
|
|
493
|
+
} catch (deleteError) {
|
|
494
|
+
throw new AggregateError([sizeError, deleteError], `${sizeError.message}; failed to delete oversized object`);
|
|
495
|
+
}
|
|
496
|
+
throw sizeError;
|
|
487
497
|
}
|
|
488
498
|
const entry = {
|
|
489
499
|
userId: ctx.userId,
|
|
@@ -569,7 +569,25 @@ function isoDate(ms) {
|
|
|
569
569
|
return new Date(ms).toISOString().slice(0, 10);
|
|
570
570
|
}
|
|
571
571
|
function hashUrlList(urls) {
|
|
572
|
-
return
|
|
572
|
+
return hashSortedUrlList(urls.map((u) => u.loc).sort());
|
|
573
|
+
}
|
|
574
|
+
function hashSortedUrlList(locs) {
|
|
575
|
+
let hi = 2166136261;
|
|
576
|
+
let lo = 3421674724;
|
|
577
|
+
for (let locIndex = 0; locIndex < locs.length; locIndex++) {
|
|
578
|
+
const loc = locs[locIndex];
|
|
579
|
+
const length = loc.length + (locIndex < locs.length - 1 ? 1 : 0);
|
|
580
|
+
for (let i = 0; i < length; i++) {
|
|
581
|
+
const c = i < loc.length ? loc.charCodeAt(i) : 10;
|
|
582
|
+
lo ^= c;
|
|
583
|
+
const loMul = Math.imul(lo, 435) >>> 0;
|
|
584
|
+
const carry = Math.floor(lo * 435 / 4294967296);
|
|
585
|
+
const hiMul = Math.imul(hi, 435) + Math.imul(lo, 1) + carry >>> 0;
|
|
586
|
+
lo = loMul;
|
|
587
|
+
hi = hiMul;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return (hi >>> 0).toString(16).padStart(8, "0") + (lo >>> 0).toString(16).padStart(8, "0");
|
|
573
591
|
}
|
|
574
592
|
function createSitemapStore(opts) {
|
|
575
593
|
const ds = opts.dataSource;
|
|
@@ -621,7 +639,7 @@ function createSitemapStore(opts) {
|
|
|
621
639
|
for await (const rec of this.loadUrls(ctx, feedpath, { includeRemoved: true })) priorByHash.set(rec.urlHash, rec);
|
|
622
640
|
const livePrior = Array.from(priorByHash.values()).filter((r) => r.removedAt == null);
|
|
623
641
|
if (livePrior.length > 0) {
|
|
624
|
-
if (
|
|
642
|
+
if (hashSortedUrlList(livePrior.map((r) => String(r.loc)).sort()) === contentHash) return {
|
|
625
643
|
added: 0,
|
|
626
644
|
removed: 0,
|
|
627
645
|
kept: livePrior.length,
|
package/dist/_chunks/layout.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { arrowToRows } from "../arrow-utils.mjs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
+
import process from "node:process";
|
|
3
4
|
import { unlinkSync } from "node:fs";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
6
|
import { join } from "node:path";
|
|
6
|
-
import process from "node:process";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { ConsoleLogger, NODE_RUNTIME, VoidLogger, createDuckDB } from "@duckdb/duckdb-wasm/dist/duckdb-node-blocking.cjs";
|
|
9
9
|
const require_ = createRequire(typeof __filename !== "undefined" ? __filename : typeof import.meta !== "undefined" ? fileURLToPath(import.meta.url) : process.cwd());
|
|
@@ -64,10 +64,15 @@ function createNodeDuckDBHandle(opts = {}) {
|
|
|
64
64
|
for (const name of names) {
|
|
65
65
|
try {
|
|
66
66
|
db.dropFile(name);
|
|
67
|
-
} catch {
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
69
|
+
if (!/not found|does not exist|unknown file/i.test(message)) throw error;
|
|
70
|
+
}
|
|
68
71
|
try {
|
|
69
72
|
unlinkSync(name);
|
|
70
|
-
} catch {
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error.code !== "ENOENT") throw error;
|
|
75
|
+
}
|
|
71
76
|
}
|
|
72
77
|
},
|
|
73
78
|
makeTempPath(ext) {
|
|
@@ -95,7 +95,11 @@ function createFilesystemManifestStore(opts) {
|
|
|
95
95
|
const tmp = `${manifestPath}.${randomBytes(6).toString("hex")}.tmp`;
|
|
96
96
|
await writeFile(tmp, JSON.stringify(data), "utf8");
|
|
97
97
|
await rename(tmp, manifestPath).catch(async (err) => {
|
|
98
|
-
|
|
98
|
+
try {
|
|
99
|
+
await unlink(tmp);
|
|
100
|
+
} catch (cleanupError) {
|
|
101
|
+
throw new AggregateError([err, cleanupError], `failed to replace manifest and remove temporary file ${tmp}`);
|
|
102
|
+
}
|
|
99
103
|
throw err;
|
|
100
104
|
});
|
|
101
105
|
}
|
|
@@ -112,7 +116,7 @@ function createFilesystemManifestStore(opts) {
|
|
|
112
116
|
async function drain() {
|
|
113
117
|
if (running) return;
|
|
114
118
|
running = true;
|
|
115
|
-
while (queue.length > 0) await queue.shift()()
|
|
119
|
+
while (queue.length > 0) await queue.shift()();
|
|
116
120
|
running = false;
|
|
117
121
|
}
|
|
118
122
|
async function registerVersionsImpl(newEntries, superseding) {
|
package/dist/index.mjs
CHANGED
|
@@ -41,6 +41,16 @@ function createNoopIngestAccumulator() {
|
|
|
41
41
|
function createIngestAccumulator(opts) {
|
|
42
42
|
const { engine, ctx, hooks, ...accOpts } = opts;
|
|
43
43
|
const acc = createRowAccumulator(accOpts);
|
|
44
|
+
function reportHookFailure(hook, error) {
|
|
45
|
+
console.warn(`[gscdump/engine] ${hook} hook failed`, error);
|
|
46
|
+
}
|
|
47
|
+
async function notifyWriteError(info) {
|
|
48
|
+
try {
|
|
49
|
+
await hooks.onWriteError(info);
|
|
50
|
+
} catch (hookError) {
|
|
51
|
+
reportHookFailure("onWriteError", hookError);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
44
54
|
async function writeOne(table, date, rows) {
|
|
45
55
|
const scope = scopeOf(ctx, table, date);
|
|
46
56
|
return (ctx.grain === "hour" ? engine.writeHour ?? (() => Promise.reject(/* @__PURE__ */ new Error("ingest accumulator: grain=hour requires engine.writeHour"))) : engine.writeDay)(scope, rows).then(() => engine.setSyncState(scope, "done")).then(async () => {
|
|
@@ -54,23 +64,31 @@ function createIngestAccumulator(opts) {
|
|
|
54
64
|
rows: rows.length
|
|
55
65
|
};
|
|
56
66
|
}).catch(async (err) => {
|
|
57
|
-
await
|
|
67
|
+
await notifyWriteError({
|
|
58
68
|
table,
|
|
59
69
|
date,
|
|
60
70
|
error: err
|
|
61
|
-
})
|
|
71
|
+
});
|
|
62
72
|
return { ok: false };
|
|
63
73
|
});
|
|
64
74
|
}
|
|
65
75
|
async function recover(table, date) {
|
|
66
76
|
const scope = scopeOf(ctx, table, date);
|
|
67
|
-
|
|
77
|
+
try {
|
|
78
|
+
await engine.setSyncState(scope, "failed", { error: "mid-continuation-skip" });
|
|
79
|
+
} catch (stateError) {
|
|
80
|
+
await notifyWriteError({
|
|
81
|
+
table,
|
|
82
|
+
date,
|
|
83
|
+
error: stateError
|
|
84
|
+
});
|
|
85
|
+
}
|
|
68
86
|
return hooks.onRecover(table, date).catch(async (err) => {
|
|
69
|
-
await
|
|
87
|
+
await notifyWriteError({
|
|
70
88
|
table,
|
|
71
89
|
date,
|
|
72
90
|
error: err
|
|
73
|
-
})
|
|
91
|
+
});
|
|
74
92
|
return false;
|
|
75
93
|
});
|
|
76
94
|
}
|
|
@@ -86,18 +104,18 @@ function createIngestAccumulator(opts) {
|
|
|
86
104
|
const tasks = [];
|
|
87
105
|
for (const [table, byDate] of buckets) for (const date of byDate.keys()) tasks.push(recover(table, date));
|
|
88
106
|
const results = await Promise.all(tasks).catch(async (err) => {
|
|
89
|
-
await
|
|
107
|
+
await notifyWriteError({
|
|
90
108
|
table: null,
|
|
91
109
|
date: null,
|
|
92
110
|
error: err
|
|
93
|
-
})
|
|
111
|
+
});
|
|
94
112
|
return [];
|
|
95
113
|
});
|
|
96
|
-
if (overflowed) await
|
|
114
|
+
if (overflowed) await notifyWriteError({
|
|
97
115
|
table: null,
|
|
98
116
|
date: null,
|
|
99
117
|
error: /* @__PURE__ */ new Error(`ingest accumulator overflow at ${totalRows} rows; recovering via forced re-sync`)
|
|
100
|
-
})
|
|
118
|
+
});
|
|
101
119
|
return {
|
|
102
120
|
flushed: 0,
|
|
103
121
|
recovered: results.filter(Boolean).length,
|
|
@@ -115,10 +133,14 @@ function createIngestAccumulator(opts) {
|
|
|
115
133
|
flushed++;
|
|
116
134
|
rowsWritten += o.rows;
|
|
117
135
|
} else failed++;
|
|
118
|
-
if (flushed > 0
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
136
|
+
if (flushed > 0 && hooks.onJobComplete) try {
|
|
137
|
+
await hooks.onJobComplete({
|
|
138
|
+
flushed,
|
|
139
|
+
rowsWritten
|
|
140
|
+
});
|
|
141
|
+
} catch (hookError) {
|
|
142
|
+
reportHookFailure("onJobComplete", hookError);
|
|
143
|
+
}
|
|
122
144
|
return {
|
|
123
145
|
flushed,
|
|
124
146
|
recovered: 0,
|
package/dist/ingest.mjs
CHANGED
|
@@ -18,7 +18,37 @@ const TABLE_DIMS = {
|
|
|
18
18
|
],
|
|
19
19
|
hourly_pages: ["hour", "page"]
|
|
20
20
|
};
|
|
21
|
+
const COMMON_HTTP_AUTHORITY_RE = /^([\w.~-]+)(?::(\d{1,5}))?$/;
|
|
22
|
+
const IPV4_HOST_RE = /^\d+(?:\.\d+){3}$/;
|
|
23
|
+
function isCommonHttpAuthority(value) {
|
|
24
|
+
const match = COMMON_HTTP_AUTHORITY_RE.exec(value);
|
|
25
|
+
if (!match) return false;
|
|
26
|
+
if (match[2] !== void 0 && Number(match[2]) > 65535) return false;
|
|
27
|
+
const host = match[1];
|
|
28
|
+
if (IPV4_HOST_RE.test(host) && host.split(".").some((part) => Number(part) > 255)) return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
21
31
|
function toPath(gscUrl) {
|
|
32
|
+
const authorityStart = gscUrl.startsWith("https://") ? 8 : gscUrl.startsWith("http://") ? 7 : -1;
|
|
33
|
+
if (authorityStart > 0) {
|
|
34
|
+
const slash = gscUrl.indexOf("/", authorityStart);
|
|
35
|
+
const query = gscUrl.indexOf("?", authorityStart);
|
|
36
|
+
const hash = gscUrl.indexOf("#", authorityStart);
|
|
37
|
+
const backslash = gscUrl.indexOf("\\", authorityStart);
|
|
38
|
+
let firstDelimiter = gscUrl.length;
|
|
39
|
+
if (slash >= 0 && slash < firstDelimiter) firstDelimiter = slash;
|
|
40
|
+
if (query >= 0 && query < firstDelimiter) firstDelimiter = query;
|
|
41
|
+
if (hash >= 0 && hash < firstDelimiter) firstDelimiter = hash;
|
|
42
|
+
const authority = gscUrl.slice(authorityStart, firstDelimiter);
|
|
43
|
+
if (backslash < 0 && !/\s/.test(gscUrl) && firstDelimiter > authorityStart && isCommonHttpAuthority(authority)) {
|
|
44
|
+
if (firstDelimiter !== slash) return "/";
|
|
45
|
+
let pathEnd = gscUrl.length;
|
|
46
|
+
if (query > slash && query < pathEnd) pathEnd = query;
|
|
47
|
+
if (hash > slash && hash < pathEnd) pathEnd = hash;
|
|
48
|
+
const path = gscUrl.slice(slash, pathEnd);
|
|
49
|
+
if (!/[^\x20-\x7E]/.test(path) && !/(?:^|\/)(?:\.|%2e){1,2}(?:\/|$)/i.test(path)) return path;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
22
52
|
try {
|
|
23
53
|
return new URL(gscUrl).pathname;
|
|
24
54
|
} catch {
|
|
@@ -209,19 +239,6 @@ function createRowAccumulator(options = {}) {
|
|
|
209
239
|
const latestDate = /* @__PURE__ */ new Map();
|
|
210
240
|
let total = 0;
|
|
211
241
|
let overflowed = false;
|
|
212
|
-
function bucketFor(table, date) {
|
|
213
|
-
let byDate = buckets.get(table);
|
|
214
|
-
if (!byDate) {
|
|
215
|
-
byDate = /* @__PURE__ */ new Map();
|
|
216
|
-
buckets.set(table, byDate);
|
|
217
|
-
}
|
|
218
|
-
let rows = byDate.get(date);
|
|
219
|
-
if (!rows) {
|
|
220
|
-
rows = [];
|
|
221
|
-
byDate.set(date, rows);
|
|
222
|
-
}
|
|
223
|
-
return rows;
|
|
224
|
-
}
|
|
225
242
|
return {
|
|
226
243
|
get totalRows() {
|
|
227
244
|
return total;
|
|
@@ -231,20 +248,30 @@ function createRowAccumulator(options = {}) {
|
|
|
231
248
|
},
|
|
232
249
|
push(table, rows) {
|
|
233
250
|
if (overflowed) return false;
|
|
251
|
+
let byDate = buckets.get(table);
|
|
252
|
+
let newestDate = trackDateBoundary ? latestDate.get(table) : void 0;
|
|
234
253
|
for (const r of rows) {
|
|
235
254
|
const t = transformGscRow(table, r, options);
|
|
236
255
|
if (!t || !t.date) continue;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const prev = latestDate.get(table);
|
|
241
|
-
if (!prev || t.date > prev) latestDate.set(table, t.date);
|
|
256
|
+
if (!byDate) {
|
|
257
|
+
byDate = /* @__PURE__ */ new Map();
|
|
258
|
+
buckets.set(table, byDate);
|
|
242
259
|
}
|
|
260
|
+
let dateRows = byDate.get(t.date);
|
|
261
|
+
if (!dateRows) {
|
|
262
|
+
dateRows = [];
|
|
263
|
+
byDate.set(t.date, dateRows);
|
|
264
|
+
}
|
|
265
|
+
dateRows.push(t.row);
|
|
266
|
+
total++;
|
|
267
|
+
if (trackDateBoundary && (!newestDate || t.date > newestDate)) newestDate = t.date;
|
|
243
268
|
if (total > maxRows) {
|
|
269
|
+
if (newestDate) latestDate.set(table, newestDate);
|
|
244
270
|
overflowed = true;
|
|
245
271
|
return false;
|
|
246
272
|
}
|
|
247
273
|
}
|
|
274
|
+
if (newestDate) latestDate.set(table, newestDate);
|
|
248
275
|
return true;
|
|
249
276
|
},
|
|
250
277
|
drain() {
|
package/dist/period/index.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { MS_PER_DAY,
|
|
1
|
+
import { MS_PER_DAY, daysAgoUtc, toIsoDate } from "gscdump/dates";
|
|
2
2
|
function defaultEndDate() {
|
|
3
|
-
return
|
|
3
|
+
return daysAgoUtc(3);
|
|
4
4
|
}
|
|
5
5
|
function defaultStartDate() {
|
|
6
|
-
return
|
|
6
|
+
return daysAgoUtc(31);
|
|
7
7
|
}
|
|
8
8
|
function periodOf(params) {
|
|
9
9
|
return {
|
package/dist/rollups.d.mts
CHANGED
|
@@ -332,26 +332,6 @@ declare const topKeywords28dRollup: RollupDef;
|
|
|
332
332
|
* coexist during a migration.
|
|
333
333
|
*/
|
|
334
334
|
declare const topKeywords28dParquetRollup: RollupDef;
|
|
335
|
-
/**
|
|
336
|
-
* Materialises canonical-query variant grouping so the read path
|
|
337
|
-
* (`buildExtrasQueries` in `resolver/compile.ts`) becomes a passthrough scan
|
|
338
|
-
* instead of two window passes (`ROW_NUMBER`/`COUNT` over `PARTITION BY
|
|
339
|
-
* query_canonical`) plus a `GROUP_CONCAT` over the whole `queries` table on
|
|
340
|
-
* every request — work that is single-threaded under DuckDB-WASM/Workers and
|
|
341
|
-
* scales with table size. See ADR-0017.
|
|
342
|
-
*
|
|
343
|
-
* One row per `query_canonical` group, columns named 1:1 with the live query's
|
|
344
|
-
* output (`joinKey`, `variantCount`, `canonicalName`, `variants`) so
|
|
345
|
-
* `mergeExtras` consumes either source unchanged. `variants` packs the top-10
|
|
346
|
-
* variants as `query:::clicks:::impressions:::position` joined by `||`,
|
|
347
|
-
* identical to the live composer.
|
|
348
|
-
*
|
|
349
|
-
* Full history (`windowDays: null`), not a trailing window: grouping metadata
|
|
350
|
-
* is global (which variant is canonical, how many variants exist) and stays
|
|
351
|
-
* stable across requests rather than shifting with each query's date range.
|
|
352
|
-
* Reflects the last sync/compaction, not the live tail — readers that need the
|
|
353
|
-
* tail can layer a recent-overlay later (the envelope carries `builtAt`).
|
|
354
|
-
*/
|
|
355
335
|
declare const queryCanonicalVariantsRollup: RollupDef;
|
|
356
336
|
/**
|
|
357
337
|
* Canonical-grained fact aggregate (ADR-0018 Gap 2): pre-sums the raw
|
package/dist/rollups.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { engineErrors } from "./errors.mjs";
|
|
|
3
3
|
import { encodeRowsToParquetFlex } from "./adapters/hyparquet.mjs";
|
|
4
4
|
import { createIndexingMetadataStore, createQueryDimStore, createSitemapStore, inspectionParquetKey, sitemapUrlsIndexPrefix } from "./_chunks/entities.mjs";
|
|
5
5
|
import { encodeJsonBigintSafe } from "@gscdump/lakehouse";
|
|
6
|
-
import { MS_PER_DAY } from "gscdump";
|
|
6
|
+
import { MS_PER_DAY } from "gscdump/dates";
|
|
7
7
|
function rollupPrefix(ctx, searchType) {
|
|
8
8
|
const base = ctx.siteId ? `u_${ctx.userId}/${ctx.siteId}/rollups` : `u_${ctx.userId}/rollups`;
|
|
9
9
|
return searchType !== void 0 && searchType !== "web" ? `${base}/${searchType}` : base;
|
|
@@ -569,6 +569,24 @@ const topKeywords28dParquetRollup = {
|
|
|
569
569
|
}));
|
|
570
570
|
}
|
|
571
571
|
};
|
|
572
|
+
const CANONICAL_VARIANT_LIMIT = 10;
|
|
573
|
+
function retainCanonicalVariant(bucket, query, clicks, impressions, sumPos) {
|
|
574
|
+
bucket.count++;
|
|
575
|
+
let insertAt = 0;
|
|
576
|
+
while (insertAt < bucket.top.length) {
|
|
577
|
+
const existing = bucket.top[insertAt];
|
|
578
|
+
if (clicks > existing.clicks || clicks === existing.clicks && query.localeCompare(existing.query) < 0) break;
|
|
579
|
+
insertAt++;
|
|
580
|
+
}
|
|
581
|
+
if (insertAt >= CANONICAL_VARIANT_LIMIT) return;
|
|
582
|
+
bucket.top.splice(insertAt, 0, {
|
|
583
|
+
query,
|
|
584
|
+
clicks,
|
|
585
|
+
impressions,
|
|
586
|
+
sumPos
|
|
587
|
+
});
|
|
588
|
+
if (bucket.top.length > CANONICAL_VARIANT_LIMIT) bucket.top.pop();
|
|
589
|
+
}
|
|
572
590
|
const queryCanonicalVariantsRollup = {
|
|
573
591
|
id: "query_canonical_variants",
|
|
574
592
|
windowDays: null,
|
|
@@ -641,28 +659,27 @@ const queryCanonicalVariantsRollup = {
|
|
|
641
659
|
});
|
|
642
660
|
for (const r of rows) {
|
|
643
661
|
const joinKey = String(r.joinKey);
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
662
|
+
let bucket = byCanonical.get(joinKey);
|
|
663
|
+
if (!bucket) {
|
|
664
|
+
bucket = {
|
|
665
|
+
count: 0,
|
|
666
|
+
top: []
|
|
667
|
+
};
|
|
668
|
+
byCanonical.set(joinKey, bucket);
|
|
669
|
+
}
|
|
670
|
+
retainCanonicalVariant(bucket, String(r.query), Number(r.clicks), Number(r.impressions), Number(r.sum_pos));
|
|
653
671
|
}
|
|
654
672
|
if (rows.length < 2e4) break;
|
|
655
673
|
cursor = String(rows[rows.length - 1].query);
|
|
656
674
|
}
|
|
657
675
|
const out = [];
|
|
658
|
-
for (const [joinKey,
|
|
659
|
-
|
|
660
|
-
const
|
|
661
|
-
const top = variants.slice(0, 10).filter((v) => v.impressions > 0);
|
|
676
|
+
for (const [joinKey, bucket] of byCanonical) {
|
|
677
|
+
const canonicalName = bucket.top[0]?.query ?? null;
|
|
678
|
+
const top = bucket.top.filter((v) => v.impressions > 0);
|
|
662
679
|
const variantsStr = top.length === 0 ? null : top.map((v) => `${v.query}:::${v.clicks}:::${v.impressions}:::${(v.sumPos / v.impressions + 1).toFixed(1)}`).join("||");
|
|
663
680
|
out.push({
|
|
664
681
|
joinKey,
|
|
665
|
-
variantCount: BigInt(
|
|
682
|
+
variantCount: BigInt(bucket.count),
|
|
666
683
|
canonicalName,
|
|
667
684
|
variants: variantsStr
|
|
668
685
|
});
|
|
@@ -1083,6 +1100,40 @@ const sitemapHealthRollup = {
|
|
|
1083
1100
|
};
|
|
1084
1101
|
}
|
|
1085
1102
|
};
|
|
1103
|
+
const RECENT_SITEMAP_CHANGE_LIMIT = 200;
|
|
1104
|
+
function isLessRecent(a, b) {
|
|
1105
|
+
return a.at < b.at || a.at === b.at && a.sequence > b.sequence;
|
|
1106
|
+
}
|
|
1107
|
+
function retainRecentSitemapChange(heap, change) {
|
|
1108
|
+
if (heap.length < RECENT_SITEMAP_CHANGE_LIMIT) {
|
|
1109
|
+
heap.push(change);
|
|
1110
|
+
let index = heap.length - 1;
|
|
1111
|
+
while (index > 0) {
|
|
1112
|
+
const parent = index - 1 >> 1;
|
|
1113
|
+
if (!isLessRecent(heap[index], heap[parent])) break;
|
|
1114
|
+
const parentValue = heap[parent];
|
|
1115
|
+
heap[parent] = heap[index];
|
|
1116
|
+
heap[index] = parentValue;
|
|
1117
|
+
index = parent;
|
|
1118
|
+
}
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
if (!isLessRecent(heap[0], change)) return;
|
|
1122
|
+
heap[0] = change;
|
|
1123
|
+
let index = 0;
|
|
1124
|
+
for (;;) {
|
|
1125
|
+
const left = index * 2 + 1;
|
|
1126
|
+
const right = left + 1;
|
|
1127
|
+
let leastRecent = index;
|
|
1128
|
+
if (left < heap.length && isLessRecent(heap[left], heap[leastRecent])) leastRecent = left;
|
|
1129
|
+
if (right < heap.length && isLessRecent(heap[right], heap[leastRecent])) leastRecent = right;
|
|
1130
|
+
if (leastRecent === index) return;
|
|
1131
|
+
const childValue = heap[leastRecent];
|
|
1132
|
+
heap[leastRecent] = heap[index];
|
|
1133
|
+
heap[index] = childValue;
|
|
1134
|
+
index = leastRecent;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1086
1137
|
const sitemapChanges28dRollup = {
|
|
1087
1138
|
id: "sitemap_changes_28d",
|
|
1088
1139
|
windowDays: 28,
|
|
@@ -1094,6 +1145,7 @@ const sitemapChanges28dRollup = {
|
|
|
1094
1145
|
const counts = /* @__PURE__ */ new Map();
|
|
1095
1146
|
const addedTop = [];
|
|
1096
1147
|
const removedTop = [];
|
|
1148
|
+
let sequence = 0;
|
|
1097
1149
|
function key(k) {
|
|
1098
1150
|
return `${k.day}\x00${k.feedpath}`;
|
|
1099
1151
|
}
|
|
@@ -1112,20 +1164,18 @@ const sitemapChanges28dRollup = {
|
|
|
1112
1164
|
added: 0,
|
|
1113
1165
|
removed: 0
|
|
1114
1166
|
};
|
|
1167
|
+
const change = {
|
|
1168
|
+
loc: d.loc,
|
|
1169
|
+
feedpath: d.feedpath,
|
|
1170
|
+
at: d.at,
|
|
1171
|
+
sequence: sequence++
|
|
1172
|
+
};
|
|
1115
1173
|
if (d.op === "added") {
|
|
1116
1174
|
cur.added += 1;
|
|
1117
|
-
addedTop
|
|
1118
|
-
loc: d.loc,
|
|
1119
|
-
feedpath: d.feedpath,
|
|
1120
|
-
at: d.at
|
|
1121
|
-
});
|
|
1175
|
+
retainRecentSitemapChange(addedTop, change);
|
|
1122
1176
|
} else {
|
|
1123
1177
|
cur.removed += 1;
|
|
1124
|
-
removedTop
|
|
1125
|
-
loc: d.loc,
|
|
1126
|
-
feedpath: d.feedpath,
|
|
1127
|
-
at: d.at
|
|
1128
|
-
});
|
|
1178
|
+
retainRecentSitemapChange(removedTop, change);
|
|
1129
1179
|
}
|
|
1130
1180
|
counts.set(k, cur);
|
|
1131
1181
|
}
|
|
@@ -1133,12 +1183,15 @@ const sitemapChanges28dRollup = {
|
|
|
1133
1183
|
if (a.day !== b.day) return a.day < b.day ? -1 : 1;
|
|
1134
1184
|
return a.feedpath < b.feedpath ? -1 : 1;
|
|
1135
1185
|
});
|
|
1136
|
-
|
|
1137
|
-
|
|
1186
|
+
const toRecentList = (heap) => heap.sort((a, b) => b.at - a.at || a.sequence - b.sequence).map(({ loc, feedpath, at }) => ({
|
|
1187
|
+
loc,
|
|
1188
|
+
feedpath,
|
|
1189
|
+
at
|
|
1190
|
+
}));
|
|
1138
1191
|
return {
|
|
1139
1192
|
days,
|
|
1140
|
-
topAdded: addedTop
|
|
1141
|
-
topRemoved: removedTop
|
|
1193
|
+
topAdded: toRecentList(addedTop),
|
|
1194
|
+
topRemoved: toRecentList(removedTop)
|
|
1142
1195
|
};
|
|
1143
1196
|
}
|
|
1144
1197
|
};
|
package/dist/sink-node.mjs
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
import { ICEBERG_SCHEMAS, assertIcebergTable } from "./_chunks/schema2.mjs";
|
|
2
|
-
import
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
const PYICEBERG_PYTHON_ENV = "GSCDUMP_ICEBERG_PYTHON";
|
|
4
|
+
const DEFAULT_PYICEBERG_PYTHON = "python3";
|
|
5
|
+
function resolvePyIcebergPython(override) {
|
|
6
|
+
return override ?? process.env[PYICEBERG_PYTHON_ENV] ?? DEFAULT_PYICEBERG_PYTHON;
|
|
7
|
+
}
|
|
8
|
+
async function runPyIcebergWriter(options) {
|
|
9
|
+
const { execFile } = await import("node:child_process");
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
execFile(options.python, [options.script], { maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
12
|
+
let parsed;
|
|
13
|
+
let parseError;
|
|
14
|
+
if (stdout.trim()) try {
|
|
15
|
+
parsed = JSON.parse(stdout);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
parseError = error;
|
|
18
|
+
}
|
|
19
|
+
if (parsed && !(err && options.rejectOnProcessError)) {
|
|
20
|
+
resolve(parsed);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (err) {
|
|
24
|
+
if (options.processErrorAsParseFailure) {
|
|
25
|
+
reject(/* @__PURE__ */ new Error(`${options.label} produced no parseable output (${err.message})${stderr ? `: ${stderr}` : ""}`));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
reject(/* @__PURE__ */ new Error(`${options.label} process failed (${err.message})${stderr ? `: ${stderr}` : ""}`));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
reject(new Error(`${options.label} produced no parseable output: ${stdout || stderr}`, { cause: parseError }));
|
|
32
|
+
}).stdin?.end(JSON.stringify(options.job));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
3
35
|
function createIcebergOverwriteWriter(opts) {
|
|
4
36
|
const { catalog, backend } = opts;
|
|
5
37
|
async function overwriteSlice(slice, rows) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gscdump/engine",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.37.
|
|
4
|
+
"version": "0.37.4",
|
|
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,12 +181,13 @@
|
|
|
181
181
|
"dependencies": {
|
|
182
182
|
"drizzle-orm": "1.0.0-rc.3",
|
|
183
183
|
"proper-lockfile": "^4.1.2",
|
|
184
|
-
"
|
|
185
|
-
"@gscdump/contracts": "0.37.
|
|
186
|
-
"gscdump": "0.37.
|
|
184
|
+
"gscdump": "0.37.4",
|
|
185
|
+
"@gscdump/contracts": "0.37.4",
|
|
186
|
+
"@gscdump/lakehouse": "0.37.4"
|
|
187
187
|
},
|
|
188
188
|
"devDependencies": {
|
|
189
189
|
"@duckdb/duckdb-wasm": "^1.32.0",
|
|
190
|
+
"@types/node": "^26.1.0",
|
|
190
191
|
"@types/proper-lockfile": "^4.1.4",
|
|
191
192
|
"aws4fetch": "^1.0.20",
|
|
192
193
|
"hyparquet": "^1.26.2",
|