@gscdump/engine 1.4.11 → 1.5.1
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/analysis-range.d.mts +7 -0
- package/dist/analysis-range.mjs +33 -0
- package/dist/entities/empty-types.d.mts +22 -0
- package/dist/entities/empty-types.mjs +58 -0
- package/dist/entities/indexing-metadata.d.mts +26 -0
- package/dist/entities/indexing-metadata.mjs +31 -0
- package/dist/entities/inspection.d.mts +240 -0
- package/dist/entities/inspection.mjs +443 -0
- package/dist/entities/io.mjs +16 -0
- package/dist/{query-dim.d.mts → entities/query-dim.d.mts} +3 -3
- package/dist/{query-dim.mjs → entities/query-dim.mjs} +4 -4
- package/dist/{sitemap-projection.mjs → entities/sitemap-projection.mjs} +1 -1
- package/dist/entities/sitemap-shared.d.mts +199 -0
- package/dist/entities/sitemap-shared.mjs +243 -0
- package/dist/entities/sitemap-write.d.mts +3 -0
- package/dist/entities/sitemap-write.mjs +528 -0
- package/dist/entities/sitemap.d.mts +3 -0
- package/dist/entities/sitemap.mjs +91 -0
- package/dist/entities.d.mts +9 -481
- package/dist/entities.mjs +7 -1380
- package/dist/rollups/canonical.d.mts +71 -0
- package/dist/rollups/canonical.mjs +335 -0
- package/dist/rollups/core.d.mts +201 -0
- package/dist/rollups/core.mjs +116 -0
- package/dist/rollups/dates.mjs +11 -0
- package/dist/rollups/defaults.d.mts +11 -0
- package/dist/rollups/defaults.mjs +17 -0
- package/dist/rollups/hourly.d.mts +38 -0
- package/dist/rollups/hourly.mjs +38 -0
- package/dist/rollups/indexing.d.mts +46 -0
- package/dist/rollups/indexing.mjs +357 -0
- package/dist/rollups/traffic.d.mts +38 -0
- package/dist/rollups/traffic.mjs +289 -0
- package/dist/rollups/windows.d.mts +90 -0
- package/dist/rollups/windows.mjs +176 -0
- package/dist/rollups.d.mts +8 -471
- package/dist/rollups.mjs +7 -1316
- package/package.json +9 -4
- /package/dist/{sitemap-projection.d.mts → entities/sitemap-projection.d.mts} +0 -0
package/dist/entities.mjs
CHANGED
|
@@ -1,1382 +1,9 @@
|
|
|
1
1
|
import { emptyTypesKey, hashSortedUrlList, hashUrl, hashUrlList, indexingMetadataIndexKey, inspectionBaseKey, inspectionEventKey, inspectionEventsPrefix, inspectionHistoryPrefix, inspectionHistoryShardKey, inspectionIndexKey, inspectionParquetKey, inspectionTransitionsMonthKey, inspectionTransitionsPrefix, parseSitemapUrlsDeltaKey, queryDimMetaKey, queryDimParquetKey, sitemapHistoryKey, sitemapIndexKey, sitemapUrlsDeltaKey, sitemapUrlsEventKey, sitemapUrlsEventSeedKey, sitemapUrlsEventsPrefix, sitemapUrlsGenerationKey, sitemapUrlsIndexKey, sitemapUrlsIndexPrefix, sitemapUrlsPendingGenerationKey, sitemapUrlsPendingGenerationsPrefix, sitemapUrlsPrefix, sitemapUrlsProjectionManifestKey, sitemapUrlsReconcileGenerationKey } from "./entity-keys.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { buildQueryDimRecords, createQueryDimStore } from "./query-dim.mjs";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
const YEAR_MONTH_RE = /^(\d{4})-(\d{2})-/;
|
|
10
|
-
const ENTITY_IO_CONCURRENCY = 8;
|
|
11
|
-
async function mapEntityIo(items, fn) {
|
|
12
|
-
if (items.length === 0) return [];
|
|
13
|
-
const results = Array.from({ length: items.length }, () => void 0);
|
|
14
|
-
let next = 0;
|
|
15
|
-
async function worker() {
|
|
16
|
-
while (true) {
|
|
17
|
-
const index = next++;
|
|
18
|
-
if (index >= items.length) return;
|
|
19
|
-
results[index] = await fn(items[index], index);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
await Promise.all(Array.from({ length: Math.min(ENTITY_IO_CONCURRENCY, items.length) }, worker));
|
|
23
|
-
return results;
|
|
24
|
-
}
|
|
25
|
-
const INSPECTION_EVENT_KEY_RE = /\/inspections\/events\/\d{4}-\d{2}\/[^/]+\.parquet$/;
|
|
26
|
-
const INSPECTION_HISTORY_MAX_BYTES = 5 * 1024 * 1024;
|
|
27
|
-
const INSPECTION_PARQUET_COLUMNS = [
|
|
28
|
-
{
|
|
29
|
-
name: "urlHash",
|
|
30
|
-
type: "VARCHAR",
|
|
31
|
-
nullable: false
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
name: "url",
|
|
35
|
-
type: "VARCHAR",
|
|
36
|
-
nullable: false
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
name: "inspectedAt",
|
|
40
|
-
type: "VARCHAR",
|
|
41
|
-
nullable: false
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
name: "indexStatus",
|
|
45
|
-
type: "VARCHAR",
|
|
46
|
-
nullable: true
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
name: "lastCrawlTime",
|
|
50
|
-
type: "VARCHAR",
|
|
51
|
-
nullable: true
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
name: "googleCanonical",
|
|
55
|
-
type: "VARCHAR",
|
|
56
|
-
nullable: true
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
name: "userCanonical",
|
|
60
|
-
type: "VARCHAR",
|
|
61
|
-
nullable: true
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
name: "coverageState",
|
|
65
|
-
type: "VARCHAR",
|
|
66
|
-
nullable: true
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
name: "robotsTxtState",
|
|
70
|
-
type: "VARCHAR",
|
|
71
|
-
nullable: true
|
|
72
|
-
},
|
|
73
|
-
{
|
|
74
|
-
name: "indexingState",
|
|
75
|
-
type: "VARCHAR",
|
|
76
|
-
nullable: true
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
name: "pageFetchState",
|
|
80
|
-
type: "VARCHAR",
|
|
81
|
-
nullable: true
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
name: "mobileUsabilityVerdict",
|
|
85
|
-
type: "VARCHAR",
|
|
86
|
-
nullable: true
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
name: "richResultsVerdict",
|
|
90
|
-
type: "VARCHAR",
|
|
91
|
-
nullable: true
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
name: "scheduleNextAt",
|
|
95
|
-
type: "BIGINT",
|
|
96
|
-
nullable: true
|
|
97
|
-
},
|
|
98
|
-
{
|
|
99
|
-
name: "scheduleConsecutiveUnchanged",
|
|
100
|
-
type: "INTEGER",
|
|
101
|
-
nullable: true
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
name: "schedulePolicyVersion",
|
|
105
|
-
type: "INTEGER",
|
|
106
|
-
nullable: true
|
|
107
|
-
}
|
|
108
|
-
];
|
|
109
|
-
const INSPECTION_EVENT_COLUMNS = [
|
|
110
|
-
...INSPECTION_PARQUET_COLUMNS,
|
|
111
|
-
{
|
|
112
|
-
name: "canonicalMismatchKind",
|
|
113
|
-
type: "VARCHAR",
|
|
114
|
-
nullable: false
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
name: "crawlingUserAgent",
|
|
118
|
-
type: "VARCHAR",
|
|
119
|
-
nullable: true
|
|
120
|
-
},
|
|
121
|
-
{
|
|
122
|
-
name: "richResultsItems",
|
|
123
|
-
type: "VARCHAR",
|
|
124
|
-
nullable: true
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
name: "sitemaps",
|
|
128
|
-
type: "VARCHAR",
|
|
129
|
-
nullable: true
|
|
130
|
-
},
|
|
131
|
-
{
|
|
132
|
-
name: "referringUrls",
|
|
133
|
-
type: "VARCHAR",
|
|
134
|
-
nullable: true
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
name: "mobileIssues",
|
|
138
|
-
type: "VARCHAR",
|
|
139
|
-
nullable: true
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
name: "inspectionResultLink",
|
|
143
|
-
type: "VARCHAR",
|
|
144
|
-
nullable: true
|
|
145
|
-
},
|
|
146
|
-
{
|
|
147
|
-
name: "firstCheckedAt",
|
|
148
|
-
type: "VARCHAR",
|
|
149
|
-
nullable: true
|
|
150
|
-
},
|
|
151
|
-
{
|
|
152
|
-
name: "checkCount",
|
|
153
|
-
type: "INTEGER",
|
|
154
|
-
nullable: true
|
|
155
|
-
},
|
|
156
|
-
{
|
|
157
|
-
name: "nextCheckAfter",
|
|
158
|
-
type: "BIGINT",
|
|
159
|
-
nullable: true
|
|
160
|
-
},
|
|
161
|
-
{
|
|
162
|
-
name: "nextCheckPriority",
|
|
163
|
-
type: "VARCHAR",
|
|
164
|
-
nullable: true
|
|
165
|
-
}
|
|
166
|
-
];
|
|
167
|
-
const CANONICAL_DIFFERENCE_KINDS = /* @__PURE__ */ new Set([
|
|
168
|
-
"none",
|
|
169
|
-
"formatting",
|
|
170
|
-
"path",
|
|
171
|
-
"cross_domain"
|
|
172
|
-
]);
|
|
173
|
-
function populateCanonicalMismatchKind(row) {
|
|
174
|
-
const current = row.canonicalMismatchKind;
|
|
175
|
-
const expected = classifyCanonicalDifference(typeof row.userCanonical === "string" ? row.userCanonical : null, typeof row.googleCanonical === "string" ? row.googleCanonical : null);
|
|
176
|
-
const missingOrInvalid = typeof current !== "string" || !CANONICAL_DIFFERENCE_KINDS.has(current);
|
|
177
|
-
row.canonicalMismatchKind = expected;
|
|
178
|
-
return missingOrInvalid || current !== expected;
|
|
179
|
-
}
|
|
180
|
-
const INSPECTION_TRANSITION_COLUMNS = [
|
|
181
|
-
{
|
|
182
|
-
name: "urlHash",
|
|
183
|
-
type: "VARCHAR",
|
|
184
|
-
nullable: false
|
|
185
|
-
},
|
|
186
|
-
{
|
|
187
|
-
name: "url",
|
|
188
|
-
type: "VARCHAR",
|
|
189
|
-
nullable: false
|
|
190
|
-
},
|
|
191
|
-
{
|
|
192
|
-
name: "changedAfter",
|
|
193
|
-
type: "VARCHAR",
|
|
194
|
-
nullable: false
|
|
195
|
-
},
|
|
196
|
-
{
|
|
197
|
-
name: "changedBefore",
|
|
198
|
-
type: "VARCHAR",
|
|
199
|
-
nullable: false
|
|
200
|
-
},
|
|
201
|
-
...GSCDUMP_INDEXING_TRANSITION_FIELDS.flatMap((field) => {
|
|
202
|
-
const capped = field[0].toUpperCase() + field.slice(1);
|
|
203
|
-
return [{
|
|
204
|
-
name: `from${capped}`,
|
|
205
|
-
type: "VARCHAR",
|
|
206
|
-
nullable: true
|
|
207
|
-
}, {
|
|
208
|
-
name: `to${capped}`,
|
|
209
|
-
type: "VARCHAR",
|
|
210
|
-
nullable: true
|
|
211
|
-
}];
|
|
212
|
-
})
|
|
213
|
-
];
|
|
214
|
-
function isStateTransition(before, after) {
|
|
215
|
-
return GSCDUMP_INDEXING_TRANSITION_FIELDS.some((field) => (before[field] ?? null) !== (after[field] ?? null));
|
|
216
|
-
}
|
|
217
|
-
function buildTransitionRow(before, after) {
|
|
218
|
-
const row = {
|
|
219
|
-
urlHash: String(after.urlHash),
|
|
220
|
-
url: String(after.url ?? before.url ?? ""),
|
|
221
|
-
changedAfter: String(before.inspectedAt ?? ""),
|
|
222
|
-
changedBefore: String(after.inspectedAt ?? "")
|
|
223
|
-
};
|
|
224
|
-
for (const field of GSCDUMP_INDEXING_TRANSITION_FIELDS) {
|
|
225
|
-
const capped = field[0].toUpperCase() + field.slice(1);
|
|
226
|
-
row[`from${capped}`] = before[field] ?? null;
|
|
227
|
-
row[`to${capped}`] = after[field] ?? null;
|
|
228
|
-
}
|
|
229
|
-
return row;
|
|
230
|
-
}
|
|
231
|
-
async function appendTransitions(ds, ctx, rows) {
|
|
232
|
-
if (rows.length === 0) return 0;
|
|
233
|
-
const byMonth = /* @__PURE__ */ new Map();
|
|
234
|
-
for (const row of rows) {
|
|
235
|
-
const month = transitionMonth(row);
|
|
236
|
-
const bucket = byMonth.get(month);
|
|
237
|
-
if (bucket) bucket.push(row);
|
|
238
|
-
else byMonth.set(month, [row]);
|
|
239
|
-
}
|
|
240
|
-
let written = 0;
|
|
241
|
-
for (const [month, monthRows] of byMonth) {
|
|
242
|
-
const key = inspectionTransitionsMonthKey(ctx, month);
|
|
243
|
-
const existingBytes = await readOptional(ds, key);
|
|
244
|
-
const existing = existingBytes ? await decodeParquetToRows(existingBytes) : [];
|
|
245
|
-
const seen = new Set(existing.map((r) => `${String(r.urlHash)}\u0000${String(r.changedBefore)}`));
|
|
246
|
-
const fresh = monthRows.filter((r) => {
|
|
247
|
-
const id = `${String(r.urlHash)}\u0000${String(r.changedBefore)}`;
|
|
248
|
-
if (seen.has(id)) return false;
|
|
249
|
-
seen.add(id);
|
|
250
|
-
return true;
|
|
251
|
-
});
|
|
252
|
-
if (fresh.length === 0) continue;
|
|
253
|
-
const merged = [...existing, ...fresh];
|
|
254
|
-
await ds.write(key, encodeRowsToParquetFlex(merged, {
|
|
255
|
-
columns: INSPECTION_TRANSITION_COLUMNS,
|
|
256
|
-
sortKey: ["urlHash"]
|
|
257
|
-
}));
|
|
258
|
-
written += fresh.length;
|
|
259
|
-
}
|
|
260
|
-
return written;
|
|
261
|
-
}
|
|
262
|
-
function transitionMonth(row) {
|
|
263
|
-
return String(row.changedBefore ?? "").slice(0, 7) || "unknown";
|
|
264
|
-
}
|
|
265
|
-
function createInspectionStore(opts) {
|
|
266
|
-
const ds = opts.dataSource;
|
|
267
|
-
function shardFor(record) {
|
|
268
|
-
const m = YEAR_MONTH_RE.exec(record.inspectedAt);
|
|
269
|
-
return m ? `${m[1]}-${m[2]}` : "unknown";
|
|
270
|
-
}
|
|
271
|
-
function randomBatchId() {
|
|
272
|
-
return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
273
|
-
}
|
|
274
|
-
return {
|
|
275
|
-
async appendHistory(ctx, records, options) {
|
|
276
|
-
if (records.length === 0) return;
|
|
277
|
-
const batchId = options?.batchId ?? randomBatchId();
|
|
278
|
-
const byMonth = /* @__PURE__ */ new Map();
|
|
279
|
-
for (const r of records) {
|
|
280
|
-
const month = shardFor(r);
|
|
281
|
-
if (!byMonth.has(month)) byMonth.set(month, []);
|
|
282
|
-
byMonth.get(month).push(r);
|
|
283
|
-
}
|
|
284
|
-
await mapEntityIo([...byMonth].map(([yearMonth, batch]) => {
|
|
285
|
-
const bytes = encodeJsonBigintSafe({
|
|
286
|
-
version: 1,
|
|
287
|
-
records: batch
|
|
288
|
-
});
|
|
289
|
-
if (bytes.byteLength > 5242880) throw new Error(`inspection history shard exceeds ${INSPECTION_HISTORY_MAX_BYTES} bytes (got ${bytes.byteLength}); split the batch`);
|
|
290
|
-
return {
|
|
291
|
-
key: inspectionHistoryShardKey(ctx, yearMonth, batchId),
|
|
292
|
-
bytes
|
|
293
|
-
};
|
|
294
|
-
}), (shard) => ds.write(shard.key, shard.bytes));
|
|
295
|
-
},
|
|
296
|
-
async loadHistory(ctx, yearMonth) {
|
|
297
|
-
const keys = await ds.list(inspectionHistoryPrefix(ctx, yearMonth));
|
|
298
|
-
if (keys.length === 0) return void 0;
|
|
299
|
-
return {
|
|
300
|
-
version: 1,
|
|
301
|
-
records: (await mapEntityIo(keys, async (key) => {
|
|
302
|
-
const bytes = await readOptional(ds, key);
|
|
303
|
-
if (!bytes) return [];
|
|
304
|
-
return (await Promise.resolve().then(() => JSON.parse(new TextDecoder().decode(bytes))).catch((err) => {
|
|
305
|
-
console.warn("[inspection.loadHistory] failed to decode shard", {
|
|
306
|
-
key,
|
|
307
|
-
error: err.message
|
|
308
|
-
});
|
|
309
|
-
}))?.records ?? [];
|
|
310
|
-
})).flat()
|
|
311
|
-
};
|
|
312
|
-
},
|
|
313
|
-
async materialize(ctx, rowIter) {
|
|
314
|
-
const rows = Array.from(rowIter);
|
|
315
|
-
rows.sort((a, b) => a.urlHash < b.urlHash ? -1 : a.urlHash > b.urlHash ? 1 : 0);
|
|
316
|
-
const bytes = encodeRowsToParquetFlex(rows, {
|
|
317
|
-
columns: INSPECTION_PARQUET_COLUMNS,
|
|
318
|
-
sortKey: ["urlHash"]
|
|
319
|
-
});
|
|
320
|
-
const key = inspectionParquetKey(ctx);
|
|
321
|
-
await ds.write(key, bytes);
|
|
322
|
-
return {
|
|
323
|
-
key,
|
|
324
|
-
rowCount: rows.length,
|
|
325
|
-
bytes: bytes.byteLength
|
|
326
|
-
};
|
|
327
|
-
},
|
|
328
|
-
async appendInspectionEvents(ctx, rows, options) {
|
|
329
|
-
if (rows.length === 0) return {
|
|
330
|
-
keys: [],
|
|
331
|
-
rowCount: 0
|
|
332
|
-
};
|
|
333
|
-
const batchId = options?.batchId ?? randomBatchId();
|
|
334
|
-
const byMonth = /* @__PURE__ */ new Map();
|
|
335
|
-
for (const r of rows) {
|
|
336
|
-
const m = YEAR_MONTH_RE.exec(r.inspectedAt);
|
|
337
|
-
const month = m ? `${m[1]}-${m[2]}` : "unknown";
|
|
338
|
-
const bucket = byMonth.get(month) ?? [];
|
|
339
|
-
bucket.push(r);
|
|
340
|
-
byMonth.set(month, bucket);
|
|
341
|
-
}
|
|
342
|
-
const files = [...byMonth].map(([month, batch]) => {
|
|
343
|
-
const bytes = encodeRowsToParquetFlex(batch, {
|
|
344
|
-
columns: INSPECTION_EVENT_COLUMNS,
|
|
345
|
-
sortKey: ["urlHash"]
|
|
346
|
-
});
|
|
347
|
-
return {
|
|
348
|
-
key: inspectionEventKey(ctx, month, batchId),
|
|
349
|
-
bytes
|
|
350
|
-
};
|
|
351
|
-
});
|
|
352
|
-
await mapEntityIo(files, (file) => ds.write(file.key, file.bytes));
|
|
353
|
-
return {
|
|
354
|
-
keys: files.map((file) => file.key),
|
|
355
|
-
rowCount: rows.length
|
|
356
|
-
};
|
|
357
|
-
},
|
|
358
|
-
async compactInspections(ctx, opts) {
|
|
359
|
-
const eventKeys = (await ds.list(`${inspectionEventsPrefix(ctx)}/`)).filter((k) => INSPECTION_EVENT_KEY_RE.test(k));
|
|
360
|
-
if (eventKeys.length === 0) return {
|
|
361
|
-
baseRowCount: 0,
|
|
362
|
-
eventsFolded: 0,
|
|
363
|
-
eventFilesDeleted: 0,
|
|
364
|
-
transitionsWritten: 0
|
|
365
|
-
};
|
|
366
|
-
const baseKey = inspectionBaseKey(ctx);
|
|
367
|
-
const baseBytes = await readOptional(ds, baseKey);
|
|
368
|
-
const baseRows = baseBytes ? await decodeParquetToRows(baseBytes) : [];
|
|
369
|
-
const latest = /* @__PURE__ */ new Map();
|
|
370
|
-
const earliestChecked = /* @__PURE__ */ new Map();
|
|
371
|
-
const transitions = [];
|
|
372
|
-
const consider = (row) => {
|
|
373
|
-
const h = String(row.urlHash);
|
|
374
|
-
const prev = latest.get(h);
|
|
375
|
-
if (!prev || String(row.inspectedAt ?? "") > String(prev.inspectedAt ?? "")) {
|
|
376
|
-
if (opts?.transitions && prev && isStateTransition(prev, row)) transitions.push(buildTransitionRow(prev, row));
|
|
377
|
-
latest.set(h, row);
|
|
378
|
-
}
|
|
379
|
-
const fc = row.firstCheckedAt;
|
|
380
|
-
if (fc != null) {
|
|
381
|
-
const fcStr = String(fc);
|
|
382
|
-
const cur = earliestChecked.get(h);
|
|
383
|
-
if (cur === void 0 || fcStr < cur) earliestChecked.set(h, fcStr);
|
|
384
|
-
}
|
|
385
|
-
};
|
|
386
|
-
for (const row of baseRows) consider(row);
|
|
387
|
-
let eventsFolded = 0;
|
|
388
|
-
const consumed = [];
|
|
389
|
-
const eventRows = [];
|
|
390
|
-
const eventFiles = await mapEntityIo(eventKeys.sort(), async (key) => {
|
|
391
|
-
const bytes = await readOptional(ds, key);
|
|
392
|
-
if (!bytes) return void 0;
|
|
393
|
-
return {
|
|
394
|
-
key,
|
|
395
|
-
rows: await decodeParquetToRows(bytes)
|
|
396
|
-
};
|
|
397
|
-
});
|
|
398
|
-
for (const file of eventFiles) {
|
|
399
|
-
if (!file) continue;
|
|
400
|
-
const { key, rows } = file;
|
|
401
|
-
consumed.push(key);
|
|
402
|
-
eventRows.push(...rows);
|
|
403
|
-
eventsFolded += rows.length;
|
|
404
|
-
}
|
|
405
|
-
eventRows.sort((a, b) => String(a.urlHash).localeCompare(String(b.urlHash)) || String(a.inspectedAt ?? "").localeCompare(String(b.inspectedAt ?? "")));
|
|
406
|
-
for (const row of eventRows) consider(row);
|
|
407
|
-
const merged = [];
|
|
408
|
-
for (const [h, row] of latest) {
|
|
409
|
-
const fc = earliestChecked.get(h);
|
|
410
|
-
if (fc !== void 0) row.firstCheckedAt = fc;
|
|
411
|
-
populateCanonicalMismatchKind(row);
|
|
412
|
-
merged.push(row);
|
|
413
|
-
}
|
|
414
|
-
const bytes = encodeRowsToParquetFlex(merged, {
|
|
415
|
-
columns: INSPECTION_EVENT_COLUMNS,
|
|
416
|
-
sortKey: ["urlHash"]
|
|
417
|
-
});
|
|
418
|
-
const transitionsWritten = opts?.transitions ? await appendTransitions(ds, ctx, transitions) : 0;
|
|
419
|
-
await ds.write(baseKey, bytes);
|
|
420
|
-
if (consumed.length > 0) await ds.delete(consumed);
|
|
421
|
-
return {
|
|
422
|
-
baseRowCount: merged.length,
|
|
423
|
-
eventsFolded,
|
|
424
|
-
eventFilesDeleted: consumed.length,
|
|
425
|
-
transitionsWritten
|
|
426
|
-
};
|
|
427
|
-
},
|
|
428
|
-
async backfillCanonicalMismatchKinds(ctx) {
|
|
429
|
-
const baseKey = inspectionBaseKey(ctx);
|
|
430
|
-
const baseBytes = await readOptional(ds, baseKey);
|
|
431
|
-
if (!baseBytes) return {
|
|
432
|
-
baseRowCount: 0,
|
|
433
|
-
rowsBackfilled: 0,
|
|
434
|
-
rewritten: false
|
|
435
|
-
};
|
|
436
|
-
const rows = await decodeParquetToRows(baseBytes);
|
|
437
|
-
let rowsBackfilled = 0;
|
|
438
|
-
for (const row of rows) if (populateCanonicalMismatchKind(row)) rowsBackfilled++;
|
|
439
|
-
if (rowsBackfilled === 0) return {
|
|
440
|
-
baseRowCount: rows.length,
|
|
441
|
-
rowsBackfilled: 0,
|
|
442
|
-
rewritten: false
|
|
443
|
-
};
|
|
444
|
-
await ds.write(baseKey, encodeRowsToParquetFlex(rows, {
|
|
445
|
-
columns: INSPECTION_EVENT_COLUMNS,
|
|
446
|
-
sortKey: ["urlHash"]
|
|
447
|
-
}));
|
|
448
|
-
return {
|
|
449
|
-
baseRowCount: rows.length,
|
|
450
|
-
rowsBackfilled,
|
|
451
|
-
rewritten: true
|
|
452
|
-
};
|
|
453
|
-
},
|
|
454
|
-
parquetUri(ctx) {
|
|
455
|
-
return ds.uri?.(inspectionParquetKey(ctx));
|
|
456
|
-
}
|
|
457
|
-
};
|
|
458
|
-
}
|
|
459
|
-
const SITEMAP_URLS_EVENT_PREFIX_RE = /\/urls\/events\/(\d{4}-\d{2}-\d{2})__[0-9a-f]+__\d+__[0-9a-f]+\.parquet$/;
|
|
460
|
-
const SITEMAP_URLS_PENDING_GENERATION_RE = /\/urls\/generations\/pending\/([0-9a-f]+)\.json$/;
|
|
461
|
-
const URLS_INDEX_COLUMNS = [
|
|
462
|
-
{
|
|
463
|
-
name: "feedpath",
|
|
464
|
-
type: "VARCHAR",
|
|
465
|
-
nullable: false
|
|
466
|
-
},
|
|
467
|
-
{
|
|
468
|
-
name: "feedpath_hash",
|
|
469
|
-
type: "VARCHAR",
|
|
470
|
-
nullable: false
|
|
471
|
-
},
|
|
472
|
-
{
|
|
473
|
-
name: "url_hash",
|
|
474
|
-
type: "VARCHAR",
|
|
475
|
-
nullable: false
|
|
476
|
-
},
|
|
477
|
-
{
|
|
478
|
-
name: "loc",
|
|
479
|
-
type: "VARCHAR",
|
|
480
|
-
nullable: false
|
|
481
|
-
},
|
|
482
|
-
{
|
|
483
|
-
name: "lastmod",
|
|
484
|
-
type: "VARCHAR",
|
|
485
|
-
nullable: true
|
|
486
|
-
},
|
|
487
|
-
{
|
|
488
|
-
name: "first_seen_at",
|
|
489
|
-
type: "BIGINT",
|
|
490
|
-
nullable: false
|
|
491
|
-
},
|
|
492
|
-
{
|
|
493
|
-
name: "last_seen_at",
|
|
494
|
-
type: "BIGINT",
|
|
495
|
-
nullable: false
|
|
496
|
-
},
|
|
497
|
-
{
|
|
498
|
-
name: "removed_at",
|
|
499
|
-
type: "BIGINT",
|
|
500
|
-
nullable: true
|
|
501
|
-
}
|
|
502
|
-
];
|
|
503
|
-
const URLS_DELTA_COLUMNS = [
|
|
504
|
-
{
|
|
505
|
-
name: "feedpath",
|
|
506
|
-
type: "VARCHAR",
|
|
507
|
-
nullable: false
|
|
508
|
-
},
|
|
509
|
-
{
|
|
510
|
-
name: "feedpath_hash",
|
|
511
|
-
type: "VARCHAR",
|
|
512
|
-
nullable: false
|
|
513
|
-
},
|
|
514
|
-
{
|
|
515
|
-
name: "url_hash",
|
|
516
|
-
type: "VARCHAR",
|
|
517
|
-
nullable: false
|
|
518
|
-
},
|
|
519
|
-
{
|
|
520
|
-
name: "op",
|
|
521
|
-
type: "VARCHAR",
|
|
522
|
-
nullable: false
|
|
523
|
-
},
|
|
524
|
-
{
|
|
525
|
-
name: "loc",
|
|
526
|
-
type: "VARCHAR",
|
|
527
|
-
nullable: false
|
|
528
|
-
},
|
|
529
|
-
{
|
|
530
|
-
name: "lastmod",
|
|
531
|
-
type: "VARCHAR",
|
|
532
|
-
nullable: true
|
|
533
|
-
},
|
|
534
|
-
{
|
|
535
|
-
name: "at",
|
|
536
|
-
type: "BIGINT",
|
|
537
|
-
nullable: false
|
|
538
|
-
},
|
|
539
|
-
{
|
|
540
|
-
name: "generation_id",
|
|
541
|
-
type: "VARCHAR",
|
|
542
|
-
nullable: true
|
|
543
|
-
}
|
|
544
|
-
];
|
|
545
|
-
const URLS_EVENT_COLUMNS = [
|
|
546
|
-
{
|
|
547
|
-
name: "feedpath",
|
|
548
|
-
type: "VARCHAR",
|
|
549
|
-
nullable: false
|
|
550
|
-
},
|
|
551
|
-
{
|
|
552
|
-
name: "feedpath_hash",
|
|
553
|
-
type: "VARCHAR",
|
|
554
|
-
nullable: false
|
|
555
|
-
},
|
|
556
|
-
{
|
|
557
|
-
name: "url_hash",
|
|
558
|
-
type: "VARCHAR",
|
|
559
|
-
nullable: false
|
|
560
|
-
},
|
|
561
|
-
{
|
|
562
|
-
name: "op",
|
|
563
|
-
type: "VARCHAR",
|
|
564
|
-
nullable: false
|
|
565
|
-
},
|
|
566
|
-
{
|
|
567
|
-
name: "loc",
|
|
568
|
-
type: "VARCHAR",
|
|
569
|
-
nullable: false
|
|
570
|
-
},
|
|
571
|
-
{
|
|
572
|
-
name: "lastmod",
|
|
573
|
-
type: "VARCHAR",
|
|
574
|
-
nullable: true
|
|
575
|
-
},
|
|
576
|
-
{
|
|
577
|
-
name: "generation_id",
|
|
578
|
-
type: "VARCHAR",
|
|
579
|
-
nullable: false
|
|
580
|
-
},
|
|
581
|
-
{
|
|
582
|
-
name: "observed_at",
|
|
583
|
-
type: "BIGINT",
|
|
584
|
-
nullable: false
|
|
585
|
-
},
|
|
586
|
-
{
|
|
587
|
-
name: "sequence",
|
|
588
|
-
type: "INTEGER",
|
|
589
|
-
nullable: false
|
|
590
|
-
},
|
|
591
|
-
{
|
|
592
|
-
name: "projects_state",
|
|
593
|
-
type: "INTEGER",
|
|
594
|
-
nullable: false
|
|
595
|
-
},
|
|
596
|
-
{
|
|
597
|
-
name: "seed_generation",
|
|
598
|
-
type: "INTEGER",
|
|
599
|
-
nullable: false
|
|
600
|
-
},
|
|
601
|
-
{
|
|
602
|
-
name: "generation_kind",
|
|
603
|
-
type: "VARCHAR",
|
|
604
|
-
nullable: false
|
|
605
|
-
},
|
|
606
|
-
{
|
|
607
|
-
name: "content_hash",
|
|
608
|
-
type: "VARCHAR",
|
|
609
|
-
nullable: true
|
|
610
|
-
},
|
|
611
|
-
{
|
|
612
|
-
name: "input_count",
|
|
613
|
-
type: "INTEGER",
|
|
614
|
-
nullable: true
|
|
615
|
-
}
|
|
616
|
-
];
|
|
617
|
-
function rowToUrlRecord(row) {
|
|
618
|
-
return {
|
|
619
|
-
feedpath: String(row.feedpath),
|
|
620
|
-
feedpathHash: String(row.feedpath_hash),
|
|
621
|
-
urlHash: String(row.url_hash),
|
|
622
|
-
loc: String(row.loc),
|
|
623
|
-
lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
|
|
624
|
-
firstSeenAt: Number(row.first_seen_at),
|
|
625
|
-
lastSeenAt: Number(row.last_seen_at),
|
|
626
|
-
removedAt: row.removed_at == null ? void 0 : Number(row.removed_at)
|
|
627
|
-
};
|
|
628
|
-
}
|
|
629
|
-
function urlRecordToRow(r) {
|
|
630
|
-
return {
|
|
631
|
-
feedpath: r.feedpath,
|
|
632
|
-
feedpath_hash: r.feedpathHash,
|
|
633
|
-
url_hash: r.urlHash,
|
|
634
|
-
loc: r.loc,
|
|
635
|
-
lastmod: r.lastmod ?? null,
|
|
636
|
-
first_seen_at: r.firstSeenAt,
|
|
637
|
-
last_seen_at: r.lastSeenAt,
|
|
638
|
-
removed_at: r.removedAt ?? null
|
|
639
|
-
};
|
|
640
|
-
}
|
|
641
|
-
function createSitemapUrlState(indexRows) {
|
|
642
|
-
const state = {
|
|
643
|
-
live: /* @__PURE__ */ new Map(),
|
|
644
|
-
removed: /* @__PURE__ */ new Map()
|
|
645
|
-
};
|
|
646
|
-
for (const row of indexRows) {
|
|
647
|
-
const record = rowToUrlRecord(row);
|
|
648
|
-
if (record.removedAt == null) state.live.set(record.urlHash, record);
|
|
649
|
-
else state.removed.set(record.urlHash, record);
|
|
650
|
-
}
|
|
651
|
-
return state;
|
|
652
|
-
}
|
|
653
|
-
function applySitemapDeltaRows(state, rows) {
|
|
654
|
-
for (const row of rows) {
|
|
655
|
-
const urlHash = String(row.url_hash);
|
|
656
|
-
const observedAt = Number(row.at);
|
|
657
|
-
const op = String(row.op);
|
|
658
|
-
if (op === "added") {
|
|
659
|
-
const previous = state.live.get(urlHash) ?? state.removed.get(urlHash);
|
|
660
|
-
state.removed.delete(urlHash);
|
|
661
|
-
state.live.set(urlHash, {
|
|
662
|
-
feedpath: String(row.feedpath),
|
|
663
|
-
feedpathHash: String(row.feedpath_hash),
|
|
664
|
-
urlHash,
|
|
665
|
-
loc: String(row.loc),
|
|
666
|
-
lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
|
|
667
|
-
firstSeenAt: previous?.firstSeenAt ?? observedAt,
|
|
668
|
-
lastSeenAt: observedAt
|
|
669
|
-
});
|
|
670
|
-
} else if (op === "removed") {
|
|
671
|
-
const previous = state.live.get(urlHash);
|
|
672
|
-
state.live.delete(urlHash);
|
|
673
|
-
if (previous) state.removed.set(urlHash, {
|
|
674
|
-
...previous,
|
|
675
|
-
removedAt: observedAt
|
|
676
|
-
});
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
function applySitemapDeltaFiles(state, files) {
|
|
681
|
-
for (const file of files) applySitemapDeltaRows(state, file.rows);
|
|
682
|
-
}
|
|
683
|
-
function sortSitemapDeltaFiles(files) {
|
|
684
|
-
return files.sort((a, b) => a.key.localeCompare(b.key));
|
|
685
|
-
}
|
|
686
|
-
async function readSitemapDeltaFiles(ds, keys) {
|
|
687
|
-
return sortSitemapDeltaFiles((await mapEntityIo(keys, async (key) => {
|
|
688
|
-
const bytes = await readOptional(ds, key);
|
|
689
|
-
return bytes ? {
|
|
690
|
-
key,
|
|
691
|
-
rows: await decodeParquetToRows(bytes)
|
|
692
|
-
} : void 0;
|
|
693
|
-
})).filter((file) => file !== void 0));
|
|
694
|
-
}
|
|
695
|
-
function dateInRange(date, range) {
|
|
696
|
-
return (!range?.from || date >= range.from) && (!range?.to || date <= range.to);
|
|
697
|
-
}
|
|
698
|
-
function createSitemapReadStore(opts) {
|
|
699
|
-
const ds = opts.dataSource;
|
|
700
|
-
const hash = opts.hash ?? hashUrl;
|
|
701
|
-
async function readJson(key) {
|
|
702
|
-
const bytes = await readOptional(ds, key);
|
|
703
|
-
return bytes === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(bytes));
|
|
704
|
-
}
|
|
705
|
-
return {
|
|
706
|
-
async loadIndex(ctx) {
|
|
707
|
-
return await readJson(sitemapIndexKey(ctx)) ?? {
|
|
708
|
-
version: 1,
|
|
709
|
-
records: {}
|
|
710
|
-
};
|
|
711
|
-
},
|
|
712
|
-
async getLatest(ctx, path) {
|
|
713
|
-
return (await readJson(sitemapIndexKey(ctx)))?.records[hash(path)];
|
|
714
|
-
},
|
|
715
|
-
async *loadUrls(ctx, feedpath, loadOpts) {
|
|
716
|
-
const feedpathHash = hash(feedpath);
|
|
717
|
-
const listedDeltaKeys = (await ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)).filter((key) => parseSitemapUrlsDeltaKey(key)?.feedpathHash === feedpathHash);
|
|
718
|
-
const listedDeltaFiles = await readSitemapDeltaFiles(ds, listedDeltaKeys);
|
|
719
|
-
const manifestBytes = await readOptional(ds, sitemapUrlsProjectionManifestKey(ctx));
|
|
720
|
-
const manifest = manifestBytes ? decodeSitemapProjectionManifest(new TextDecoder().decode(manifestBytes)) : void 0;
|
|
721
|
-
const indexRows = await readOptional(ds, sitemapUrlsIndexKey(ctx, feedpathHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []);
|
|
722
|
-
const currentDeltaKeys = new Set(selectSitemapProjectionFiles([], listedDeltaKeys, manifest).deltaKeys);
|
|
723
|
-
const state = createSitemapUrlState(indexRows);
|
|
724
|
-
applySitemapDeltaFiles(state, listedDeltaFiles.filter((file) => currentDeltaKeys.has(file.key)));
|
|
725
|
-
for (const record of state.live.values()) yield record;
|
|
726
|
-
if (loadOpts?.includeRemoved) for (const record of state.removed.values()) yield record;
|
|
727
|
-
},
|
|
728
|
-
async *loadDeltas(ctx, dateRange) {
|
|
729
|
-
const [listedKeys, manifestBytes] = await Promise.all([ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`), readOptional(ds, sitemapUrlsProjectionManifestKey(ctx))]);
|
|
730
|
-
const keys = selectSitemapProjectionFiles([], listedKeys, manifestBytes ? decodeSitemapProjectionManifest(new TextDecoder().decode(manifestBytes)) : void 0).deltaKeys.filter((key) => {
|
|
731
|
-
const parsed = parseSitemapUrlsDeltaKey(key);
|
|
732
|
-
return Boolean(parsed && dateInRange(parsed.date, dateRange));
|
|
733
|
-
});
|
|
734
|
-
const files = await readSitemapDeltaFiles(ds, keys);
|
|
735
|
-
for (const file of files) for (const row of file.rows) {
|
|
736
|
-
const op = String(row.op);
|
|
737
|
-
if (op !== "added" && op !== "removed") continue;
|
|
738
|
-
yield {
|
|
739
|
-
feedpath: String(row.feedpath),
|
|
740
|
-
feedpathHash: String(row.feedpath_hash),
|
|
741
|
-
urlHash: String(row.url_hash),
|
|
742
|
-
op,
|
|
743
|
-
loc: String(row.loc),
|
|
744
|
-
lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
|
|
745
|
-
at: Number(row.at)
|
|
746
|
-
};
|
|
747
|
-
}
|
|
748
|
-
},
|
|
749
|
-
async *loadEvents(ctx, dateRange) {
|
|
750
|
-
const listedEventKeys = await ds.list(`${sitemapUrlsEventsPrefix(ctx)}/`);
|
|
751
|
-
const pendingKeys = await ds.list(`${sitemapUrlsPendingGenerationsPrefix(ctx)}/`);
|
|
752
|
-
const pendingEvents = new Set((await mapEntityIo(pendingKeys, (key) => readJson(key))).filter((pending) => pending !== void 0).map((pending) => pending.eventKey));
|
|
753
|
-
const keys = listedEventKeys.filter((key) => {
|
|
754
|
-
const match = SITEMAP_URLS_EVENT_PREFIX_RE.exec(key);
|
|
755
|
-
return !pendingEvents.has(key) && Boolean(match?.[1] && dateInRange(match[1], dateRange));
|
|
756
|
-
}).sort();
|
|
757
|
-
for (const key of keys) {
|
|
758
|
-
const bytes = await readOptional(ds, key);
|
|
759
|
-
if (!bytes) continue;
|
|
760
|
-
const rows = await decodeParquetToRows(bytes);
|
|
761
|
-
rows.sort((a, b) => Number(a.observed_at) - Number(b.observed_at) || Number(a.sequence) - Number(b.sequence) || String(a.url_hash).localeCompare(String(b.url_hash)));
|
|
762
|
-
for (const row of rows) {
|
|
763
|
-
const op = String(row.op);
|
|
764
|
-
if (op !== "added" && op !== "removed") continue;
|
|
765
|
-
yield {
|
|
766
|
-
feedpath: String(row.feedpath),
|
|
767
|
-
feedpathHash: String(row.feedpath_hash),
|
|
768
|
-
urlHash: String(row.url_hash),
|
|
769
|
-
op,
|
|
770
|
-
loc: String(row.loc),
|
|
771
|
-
lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
|
|
772
|
-
generationId: String(row.generation_id),
|
|
773
|
-
observedAt: Number(row.observed_at),
|
|
774
|
-
sequence: Number(row.sequence),
|
|
775
|
-
projectsState: Boolean(row.projects_state)
|
|
776
|
-
};
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
};
|
|
781
|
-
}
|
|
782
|
-
function createSitemapStore(opts) {
|
|
783
|
-
const ds = opts.dataSource;
|
|
784
|
-
const hash = opts.hash ?? hashUrl;
|
|
785
|
-
const now = opts.now ?? (() => Date.now());
|
|
786
|
-
const withMutation = opts.withMutation;
|
|
787
|
-
const readStore = createSitemapReadStore({
|
|
788
|
-
dataSource: ds,
|
|
789
|
-
hash
|
|
790
|
-
});
|
|
791
|
-
async function readJson(key) {
|
|
792
|
-
const bytes = await readOptional(ds, key);
|
|
793
|
-
return bytes === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(bytes));
|
|
794
|
-
}
|
|
795
|
-
function writeJson(key, value) {
|
|
796
|
-
return ds.write(key, encodeJsonBigintSafe(value));
|
|
797
|
-
}
|
|
798
|
-
async function readProjectionManifest(ctx) {
|
|
799
|
-
const bytes = await readOptional(ds, sitemapUrlsProjectionManifestKey(ctx));
|
|
800
|
-
return bytes ? decodeSitemapProjectionManifest(new TextDecoder().decode(bytes)) : emptySitemapProjectionManifest();
|
|
801
|
-
}
|
|
802
|
-
async function deleteExpiredProjectionDeltas(manifest, deltaKeys, currentTime) {
|
|
803
|
-
const expired = deltaKeys.filter((key) => {
|
|
804
|
-
const parsed = parseSitemapUrlsDeltaKey(key);
|
|
805
|
-
const feed = parsed ? manifest.feeds[parsed.feedpathHash] : void 0;
|
|
806
|
-
return Boolean(feed && key <= feed.compactedThrough && currentTime - feed.publishedAt >= 9e5);
|
|
807
|
-
});
|
|
808
|
-
if (expired.length > 0) await ds.delete(expired);
|
|
809
|
-
}
|
|
810
|
-
async function publishCurrentProjection(ctx, feedpathHash, rows, consumedDeltas, manifest, publishedAt) {
|
|
811
|
-
const bytes = encodeRowsToParquetFlex(rows.map(urlRecordToRow), {
|
|
812
|
-
columns: URLS_INDEX_COLUMNS,
|
|
813
|
-
sortKey: ["feedpath_hash", "url_hash"]
|
|
814
|
-
});
|
|
815
|
-
await ds.write(sitemapUrlsIndexKey(ctx, feedpathHash), bytes);
|
|
816
|
-
const compactedThrough = consumedDeltas.at(-1)?.key;
|
|
817
|
-
if (!compactedThrough) return manifest;
|
|
818
|
-
const next = withSitemapProjectionFeed(manifest, feedpathHash, {
|
|
819
|
-
compactedThrough,
|
|
820
|
-
publishedAt
|
|
821
|
-
});
|
|
822
|
-
await writeJson(sitemapUrlsProjectionManifestKey(ctx), next);
|
|
823
|
-
return next;
|
|
824
|
-
}
|
|
825
|
-
function normalizedEventRows(rows) {
|
|
826
|
-
return rows.map((row) => ({
|
|
827
|
-
feedpath: String(row.feedpath),
|
|
828
|
-
feedpathHash: String(row.feedpath_hash),
|
|
829
|
-
urlHash: String(row.url_hash),
|
|
830
|
-
op: String(row.op),
|
|
831
|
-
loc: String(row.loc),
|
|
832
|
-
lastmod: row.lastmod == null ? null : String(row.lastmod),
|
|
833
|
-
generationId: String(row.generation_id),
|
|
834
|
-
observedAt: Number(row.observed_at),
|
|
835
|
-
sequence: Number(row.sequence),
|
|
836
|
-
projectsState: Boolean(row.projects_state),
|
|
837
|
-
seedGeneration: Boolean(row.seed_generation),
|
|
838
|
-
generationKind: String(row.generation_kind),
|
|
839
|
-
contentHash: row.content_hash == null ? null : String(row.content_hash),
|
|
840
|
-
inputCount: row.input_count == null ? null : Number(row.input_count)
|
|
841
|
-
})).sort((a, b) => a.observedAt - b.observedAt || a.sequence - b.sequence || a.urlHash.localeCompare(b.urlHash) || a.op.localeCompare(b.op));
|
|
842
|
-
}
|
|
843
|
-
function eventDigest(rows) {
|
|
844
|
-
return hashUrl(JSON.stringify(normalizedEventRows(rows)));
|
|
845
|
-
}
|
|
846
|
-
function assertGenerationAccepted(generation, checkpoint, scope) {
|
|
847
|
-
if (!checkpoint) return "newer";
|
|
848
|
-
if (generation.id === checkpoint.generationId) {
|
|
849
|
-
if (generation.observedAt === checkpoint.observedAt) return "same";
|
|
850
|
-
throw new Error(`sitemap generation conflict for ${scope}: generation ${generation.id} changed observedAt`);
|
|
851
|
-
}
|
|
852
|
-
if (generation.observedAt > checkpoint.observedAt) return "newer";
|
|
853
|
-
const reason = generation.observedAt === checkpoint.observedAt ? "ambiguous timestamp" : "stale generation";
|
|
854
|
-
throw new Error(`sitemap generation conflict for ${scope}: ${reason}`);
|
|
855
|
-
}
|
|
856
|
-
async function ensureEvents(ctx, feedpathHash, generation, rows) {
|
|
857
|
-
const expectedDigest = eventDigest(rows);
|
|
858
|
-
if (rows.length === 0) return {
|
|
859
|
-
rows: [],
|
|
860
|
-
digest: expectedDigest
|
|
861
|
-
};
|
|
862
|
-
const key = sitemapUrlsEventKey(ctx, feedpathHash, generation);
|
|
863
|
-
await writeJson(sitemapUrlsPendingGenerationKey(ctx, feedpathHash), {
|
|
864
|
-
version: 1,
|
|
865
|
-
generationId: generation.id,
|
|
866
|
-
observedAt: generation.observedAt,
|
|
867
|
-
eventKey: key,
|
|
868
|
-
eventDigest: expectedDigest
|
|
869
|
-
});
|
|
870
|
-
const existing = await readOptional(ds, key);
|
|
871
|
-
if (existing) {
|
|
872
|
-
const existingRows = await decodeParquetToRows(existing);
|
|
873
|
-
const existingDigest = eventDigest(existingRows);
|
|
874
|
-
if (existingDigest !== expectedDigest) throw new Error(`sitemap generation conflict for ${feedpathHash}: immutable event digest changed`);
|
|
875
|
-
return {
|
|
876
|
-
rows: existingRows,
|
|
877
|
-
digest: existingDigest
|
|
878
|
-
};
|
|
879
|
-
}
|
|
880
|
-
const bytes = encodeRowsToParquetFlex(rows, {
|
|
881
|
-
columns: URLS_EVENT_COLUMNS,
|
|
882
|
-
sortKey: ["sequence", "url_hash"]
|
|
883
|
-
});
|
|
884
|
-
await ds.write(key, bytes);
|
|
885
|
-
return {
|
|
886
|
-
rows: [...rows],
|
|
887
|
-
digest: expectedDigest
|
|
888
|
-
};
|
|
889
|
-
}
|
|
890
|
-
function eventRow(generation, row, metadata) {
|
|
891
|
-
return {
|
|
892
|
-
feedpath: row.feedpath,
|
|
893
|
-
feedpath_hash: row.feedpath_hash,
|
|
894
|
-
url_hash: row.url_hash,
|
|
895
|
-
op: row.op,
|
|
896
|
-
loc: row.loc,
|
|
897
|
-
lastmod: row.lastmod,
|
|
898
|
-
generation_id: generation.id,
|
|
899
|
-
observed_at: generation.observedAt,
|
|
900
|
-
sequence: metadata.sequence,
|
|
901
|
-
projects_state: metadata.projectsState ? 1 : 0,
|
|
902
|
-
seed_generation: metadata.seedGeneration ? 1 : 0,
|
|
903
|
-
generation_kind: metadata.generationKind,
|
|
904
|
-
content_hash: metadata.contentHash ?? null,
|
|
905
|
-
input_count: metadata.inputCount ?? null
|
|
906
|
-
};
|
|
907
|
-
}
|
|
908
|
-
function stateRowsFromEvents(rows) {
|
|
909
|
-
return rows.filter((row) => Boolean(row.projects_state)).map((row) => ({
|
|
910
|
-
feedpath: row.feedpath,
|
|
911
|
-
feedpath_hash: row.feedpath_hash,
|
|
912
|
-
url_hash: row.url_hash,
|
|
913
|
-
op: row.op,
|
|
914
|
-
loc: row.loc,
|
|
915
|
-
lastmod: row.lastmod,
|
|
916
|
-
at: row.observed_at,
|
|
917
|
-
generation_id: row.generation_id
|
|
918
|
-
}));
|
|
919
|
-
}
|
|
920
|
-
function checkpointFromEvents(rows, digest) {
|
|
921
|
-
const first = rows[0];
|
|
922
|
-
if (!first) throw new Error("cannot checkpoint an empty sitemap event file");
|
|
923
|
-
const generationId = String(first.generation_id);
|
|
924
|
-
const observedAt = Number(first.observed_at);
|
|
925
|
-
for (const row of rows) if (String(row.generation_id) !== generationId || Number(row.observed_at) !== observedAt) throw new Error("sitemap event file contains multiple generations");
|
|
926
|
-
if (String(first.generation_kind) === "snapshot") {
|
|
927
|
-
const contentHash = String(first.content_hash);
|
|
928
|
-
const inputCount = Number(first.input_count);
|
|
929
|
-
const stateRows = stateRowsFromEvents(rows);
|
|
930
|
-
const added = stateRows.filter((row) => String(row.op) === "added").length;
|
|
931
|
-
return {
|
|
932
|
-
_tag: "snapshot",
|
|
933
|
-
version: 1,
|
|
934
|
-
generationId,
|
|
935
|
-
observedAt,
|
|
936
|
-
eventDigest: digest,
|
|
937
|
-
result: {
|
|
938
|
-
added,
|
|
939
|
-
removed: stateRows.filter((row) => String(row.op) === "removed").length,
|
|
940
|
-
kept: Math.max(0, inputCount - added),
|
|
941
|
-
contentHash,
|
|
942
|
-
unchanged: stateRows.length === 0
|
|
943
|
-
}
|
|
944
|
-
};
|
|
945
|
-
}
|
|
946
|
-
return {
|
|
947
|
-
_tag: "reconcile",
|
|
948
|
-
version: 1,
|
|
949
|
-
generationId,
|
|
950
|
-
observedAt,
|
|
951
|
-
eventDigest: digest
|
|
952
|
-
};
|
|
953
|
-
}
|
|
954
|
-
async function projectEvents(ctx, feedpathHash, rows, digest) {
|
|
955
|
-
const checkpoint = checkpointFromEvents(rows, digest);
|
|
956
|
-
const generation = {
|
|
957
|
-
id: checkpoint.generationId,
|
|
958
|
-
observedAt: checkpoint.observedAt
|
|
959
|
-
};
|
|
960
|
-
const stateRows = stateRowsFromEvents(rows);
|
|
961
|
-
if (stateRows.length > 0) {
|
|
962
|
-
const bytes = encodeRowsToParquetFlex(stateRows, {
|
|
963
|
-
columns: URLS_DELTA_COLUMNS,
|
|
964
|
-
sortKey: ["url_hash"]
|
|
965
|
-
});
|
|
966
|
-
await ds.write(sitemapUrlsDeltaKey(ctx, feedpathHash, generation), bytes);
|
|
967
|
-
}
|
|
968
|
-
if (rows.some((row) => Boolean(row.seed_generation))) await writeJson(sitemapUrlsEventSeedKey(ctx, feedpathHash), {
|
|
969
|
-
version: 1,
|
|
970
|
-
generationId: checkpoint.generationId,
|
|
971
|
-
observedAt: checkpoint.observedAt
|
|
972
|
-
});
|
|
973
|
-
await writeJson(sitemapUrlsGenerationKey(ctx, feedpathHash), checkpoint);
|
|
974
|
-
return checkpoint;
|
|
975
|
-
}
|
|
976
|
-
async function repairFeedProjection(ctx, feedpathHash) {
|
|
977
|
-
const pendingKey = sitemapUrlsPendingGenerationKey(ctx, feedpathHash);
|
|
978
|
-
const [checkpoint, pending] = await Promise.all([readJson(sitemapUrlsGenerationKey(ctx, feedpathHash)), readJson(pendingKey)]);
|
|
979
|
-
if (!pending) return checkpoint;
|
|
980
|
-
const pendingGeneration = {
|
|
981
|
-
_tag: "complete",
|
|
982
|
-
id: pending.generationId,
|
|
983
|
-
observedAt: pending.observedAt
|
|
984
|
-
};
|
|
985
|
-
if (checkpoint?.generationId === pending.generationId && checkpoint.observedAt !== pending.observedAt) throw new Error(`sitemap generation conflict for ${feedpathHash}: generation ${pending.generationId} changed observedAt`);
|
|
986
|
-
if (checkpoint && pending.observedAt < checkpoint.observedAt) {
|
|
987
|
-
await ds.delete([pendingKey]);
|
|
988
|
-
return checkpoint;
|
|
989
|
-
}
|
|
990
|
-
const position = assertGenerationAccepted(pendingGeneration, checkpoint, feedpathHash);
|
|
991
|
-
const eventBytes = await readOptional(ds, pending.eventKey);
|
|
992
|
-
if (!eventBytes) {
|
|
993
|
-
await ds.delete([pendingKey]);
|
|
994
|
-
return checkpoint;
|
|
995
|
-
}
|
|
996
|
-
const rows = await decodeParquetToRows(eventBytes);
|
|
997
|
-
const digest = eventDigest(rows);
|
|
998
|
-
if (digest !== pending.eventDigest) throw new Error(`sitemap generation conflict for ${feedpathHash}: pending event digest changed`);
|
|
999
|
-
if (position === "same") {
|
|
1000
|
-
if (checkpoint?.eventDigest !== digest) throw new Error(`sitemap generation conflict for ${feedpathHash}: checkpoint digest changed`);
|
|
1001
|
-
await ds.delete([pendingKey]);
|
|
1002
|
-
return checkpoint;
|
|
1003
|
-
}
|
|
1004
|
-
const repaired = await projectEvents(ctx, feedpathHash, rows, digest);
|
|
1005
|
-
await ds.delete([pendingKey]);
|
|
1006
|
-
return repaired;
|
|
1007
|
-
}
|
|
1008
|
-
return {
|
|
1009
|
-
...readStore,
|
|
1010
|
-
writeSnapshot(ctx, records) {
|
|
1011
|
-
return withMutation(ctx, async () => {
|
|
1012
|
-
if (records.length === 0) return;
|
|
1013
|
-
const indexKey = sitemapIndexKey(ctx);
|
|
1014
|
-
const index = await readJson(indexKey) ?? {
|
|
1015
|
-
version: 1,
|
|
1016
|
-
records: {}
|
|
1017
|
-
};
|
|
1018
|
-
const stamp = now();
|
|
1019
|
-
const historyDocs = /* @__PURE__ */ new Map();
|
|
1020
|
-
for (const record of records) {
|
|
1021
|
-
const feedpathHash = hash(record.path);
|
|
1022
|
-
index.records[feedpathHash] = record;
|
|
1023
|
-
historyDocs.set(sitemapHistoryKey(ctx, feedpathHash, stamp), {
|
|
1024
|
-
version: 1,
|
|
1025
|
-
path: record.path,
|
|
1026
|
-
capturedAt: record.capturedAt,
|
|
1027
|
-
record
|
|
1028
|
-
});
|
|
1029
|
-
}
|
|
1030
|
-
await mapEntityIo([...historyDocs], ([key, doc]) => writeJson(key, doc));
|
|
1031
|
-
await writeJson(indexKey, index);
|
|
1032
|
-
});
|
|
1033
|
-
},
|
|
1034
|
-
snapshotUrls(ctx, generation, feedpath, urls) {
|
|
1035
|
-
return withMutation(ctx, async () => {
|
|
1036
|
-
const feedpathHash = hash(feedpath);
|
|
1037
|
-
const contentHash = hashUrlList(urls);
|
|
1038
|
-
const checkpoint = await repairFeedProjection(ctx, feedpathHash);
|
|
1039
|
-
if (assertGenerationAccepted(generation, checkpoint, feedpathHash) === "same") {
|
|
1040
|
-
if (checkpoint?._tag !== "snapshot" || checkpoint.result.contentHash !== contentHash) throw new Error(`sitemap generation conflict for ${feedpathHash}: snapshot input changed`);
|
|
1041
|
-
return checkpoint.result;
|
|
1042
|
-
}
|
|
1043
|
-
if (assertGenerationAccepted(generation, await readJson(sitemapUrlsReconcileGenerationKey(ctx)), "site reconciliation") === "same") throw new Error("sitemap generation conflict: site generation was already reconciled");
|
|
1044
|
-
const priorByHash = /* @__PURE__ */ new Map();
|
|
1045
|
-
for await (const record of readStore.loadUrls(ctx, feedpath, { includeRemoved: true })) priorByHash.set(record.urlHash, record);
|
|
1046
|
-
const livePrior = [...priorByHash.values()].filter((record) => record.removedAt == null);
|
|
1047
|
-
const incomingByHash = /* @__PURE__ */ new Map();
|
|
1048
|
-
for (const url of urls) incomingByHash.set(hash(url.loc), url);
|
|
1049
|
-
const deltaRows = [];
|
|
1050
|
-
let added = 0;
|
|
1051
|
-
let removed = 0;
|
|
1052
|
-
let kept = 0;
|
|
1053
|
-
for (const [urlHash, url] of incomingByHash) {
|
|
1054
|
-
const previous = priorByHash.get(urlHash);
|
|
1055
|
-
if (!previous || previous.removedAt != null) {
|
|
1056
|
-
added++;
|
|
1057
|
-
deltaRows.push({
|
|
1058
|
-
feedpath,
|
|
1059
|
-
feedpath_hash: feedpathHash,
|
|
1060
|
-
url_hash: urlHash,
|
|
1061
|
-
op: "added",
|
|
1062
|
-
loc: url.loc,
|
|
1063
|
-
lastmod: url.lastmod ?? null,
|
|
1064
|
-
at: generation.observedAt,
|
|
1065
|
-
generation_id: generation.id
|
|
1066
|
-
});
|
|
1067
|
-
} else kept++;
|
|
1068
|
-
}
|
|
1069
|
-
for (const [urlHash, previous] of priorByHash) if (previous.removedAt == null && !incomingByHash.has(urlHash)) {
|
|
1070
|
-
removed++;
|
|
1071
|
-
deltaRows.push({
|
|
1072
|
-
feedpath,
|
|
1073
|
-
feedpath_hash: feedpathHash,
|
|
1074
|
-
url_hash: urlHash,
|
|
1075
|
-
op: "removed",
|
|
1076
|
-
loc: previous.loc,
|
|
1077
|
-
lastmod: previous.lastmod ?? null,
|
|
1078
|
-
at: generation.observedAt,
|
|
1079
|
-
generation_id: generation.id
|
|
1080
|
-
});
|
|
1081
|
-
}
|
|
1082
|
-
const seedKey = sitemapUrlsEventSeedKey(ctx, feedpathHash);
|
|
1083
|
-
const seed = await readJson(seedKey);
|
|
1084
|
-
const isSeedGeneration = seed === void 0 || seed.generationId === generation.id;
|
|
1085
|
-
const seedRows = isSeedGeneration ? livePrior.map((record) => ({
|
|
1086
|
-
feedpath,
|
|
1087
|
-
feedpath_hash: feedpathHash,
|
|
1088
|
-
url_hash: record.urlHash,
|
|
1089
|
-
op: "added",
|
|
1090
|
-
loc: record.loc,
|
|
1091
|
-
lastmod: record.lastmod ?? null
|
|
1092
|
-
})) : [];
|
|
1093
|
-
const actualSequence = seedRows.length > 0 ? 1 : 0;
|
|
1094
|
-
const inputCount = incomingByHash.size;
|
|
1095
|
-
const persistedEvents = await ensureEvents(ctx, feedpathHash, generation, [...seedRows.map((row) => eventRow(generation, row, {
|
|
1096
|
-
sequence: 0,
|
|
1097
|
-
projectsState: false,
|
|
1098
|
-
seedGeneration: isSeedGeneration,
|
|
1099
|
-
generationKind: "snapshot",
|
|
1100
|
-
contentHash,
|
|
1101
|
-
inputCount
|
|
1102
|
-
})), ...deltaRows.map((row) => eventRow(generation, row, {
|
|
1103
|
-
sequence: actualSequence,
|
|
1104
|
-
projectsState: true,
|
|
1105
|
-
seedGeneration: isSeedGeneration,
|
|
1106
|
-
generationKind: "snapshot",
|
|
1107
|
-
contentHash,
|
|
1108
|
-
inputCount
|
|
1109
|
-
}))]);
|
|
1110
|
-
const result = {
|
|
1111
|
-
added,
|
|
1112
|
-
removed,
|
|
1113
|
-
kept,
|
|
1114
|
-
contentHash,
|
|
1115
|
-
unchanged: deltaRows.length === 0
|
|
1116
|
-
};
|
|
1117
|
-
if (persistedEvents.rows.length > 0) {
|
|
1118
|
-
if ((await projectEvents(ctx, feedpathHash, persistedEvents.rows, persistedEvents.digest))._tag !== "snapshot") throw new Error(`sitemap generation conflict for ${feedpathHash}: expected snapshot event`);
|
|
1119
|
-
await ds.delete([sitemapUrlsPendingGenerationKey(ctx, feedpathHash)]);
|
|
1120
|
-
} else {
|
|
1121
|
-
if (isSeedGeneration) await writeJson(seedKey, {
|
|
1122
|
-
version: 1,
|
|
1123
|
-
generationId: generation.id,
|
|
1124
|
-
observedAt: generation.observedAt
|
|
1125
|
-
});
|
|
1126
|
-
await writeJson(sitemapUrlsGenerationKey(ctx, feedpathHash), {
|
|
1127
|
-
_tag: "snapshot",
|
|
1128
|
-
version: 1,
|
|
1129
|
-
generationId: generation.id,
|
|
1130
|
-
observedAt: generation.observedAt,
|
|
1131
|
-
eventDigest: persistedEvents.digest,
|
|
1132
|
-
result
|
|
1133
|
-
});
|
|
1134
|
-
}
|
|
1135
|
-
return result;
|
|
1136
|
-
});
|
|
1137
|
-
},
|
|
1138
|
-
compactUrls(ctx, compactOpts = {}) {
|
|
1139
|
-
return withMutation(ctx, async () => {
|
|
1140
|
-
const startedAt = now();
|
|
1141
|
-
const deadlineMs = compactOpts.deadlineMs ?? Number.POSITIVE_INFINITY;
|
|
1142
|
-
const maxFeedpaths = compactOpts.maxFeedpaths ?? Number.POSITIVE_INFINITY;
|
|
1143
|
-
for (const key of await ds.list(`${sitemapUrlsPendingGenerationsPrefix(ctx)}/`)) {
|
|
1144
|
-
const feedpathHash = SITEMAP_URLS_PENDING_GENERATION_RE.exec(key)?.[1];
|
|
1145
|
-
if (feedpathHash) await repairFeedProjection(ctx, feedpathHash);
|
|
1146
|
-
}
|
|
1147
|
-
const deltaKeys = await ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`);
|
|
1148
|
-
let projectionManifest = await readProjectionManifest(ctx);
|
|
1149
|
-
await deleteExpiredProjectionDeltas(projectionManifest, deltaKeys, startedAt);
|
|
1150
|
-
const activeDeltaKeys = selectSitemapProjectionFiles([], deltaKeys, projectionManifest).deltaKeys;
|
|
1151
|
-
const deltasByFeed = /* @__PURE__ */ new Map();
|
|
1152
|
-
for (const key of activeDeltaKeys) {
|
|
1153
|
-
const feedpathHash = parseSitemapUrlsDeltaKey(key)?.feedpathHash;
|
|
1154
|
-
if (!feedpathHash) continue;
|
|
1155
|
-
const keys = deltasByFeed.get(feedpathHash) ?? [];
|
|
1156
|
-
keys.push(key);
|
|
1157
|
-
deltasByFeed.set(feedpathHash, keys);
|
|
1158
|
-
}
|
|
1159
|
-
const totalFeedpaths = deltasByFeed.size;
|
|
1160
|
-
let compactedFeedpaths = 0;
|
|
1161
|
-
for (const [feedpathHash, feedDeltaKeys] of deltasByFeed) {
|
|
1162
|
-
if (compactedFeedpaths > 0 && (compactedFeedpaths >= maxFeedpaths || now() - startedAt >= deadlineMs)) break;
|
|
1163
|
-
const [indexRows, deltaFiles] = await Promise.all([readOptional(ds, sitemapUrlsIndexKey(ctx, feedpathHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), readSitemapDeltaFiles(ds, feedDeltaKeys)]);
|
|
1164
|
-
const state = createSitemapUrlState(indexRows);
|
|
1165
|
-
applySitemapDeltaFiles(state, deltaFiles);
|
|
1166
|
-
projectionManifest = await publishCurrentProjection(ctx, feedpathHash, [...state.live.values(), ...state.removed.values()].sort((a, b) => a.urlHash.localeCompare(b.urlHash)), deltaFiles, projectionManifest, now());
|
|
1167
|
-
compactedFeedpaths++;
|
|
1168
|
-
}
|
|
1169
|
-
return {
|
|
1170
|
-
compactedFeedpaths,
|
|
1171
|
-
remainingFeedpaths: totalFeedpaths - compactedFeedpaths
|
|
1172
|
-
};
|
|
1173
|
-
});
|
|
1174
|
-
},
|
|
1175
|
-
reconcile(ctx, generation, { liveFeedpaths }) {
|
|
1176
|
-
return withMutation(ctx, async () => {
|
|
1177
|
-
const liveHashes = new Set(liveFeedpaths.map((feedpath) => hash(feedpath)));
|
|
1178
|
-
const inputDigest = hashSortedUrlList([...liveHashes]);
|
|
1179
|
-
const siteCheckpoint = await readJson(sitemapUrlsReconcileGenerationKey(ctx));
|
|
1180
|
-
if (assertGenerationAccepted(generation, siteCheckpoint, "site reconciliation") === "same") {
|
|
1181
|
-
if (siteCheckpoint?.inputDigest !== inputDigest) throw new Error("sitemap generation conflict: reconcile input changed");
|
|
1182
|
-
return {
|
|
1183
|
-
feedpathsPruned: 0,
|
|
1184
|
-
urlsRemoved: 0
|
|
1185
|
-
};
|
|
1186
|
-
}
|
|
1187
|
-
const pendingFeedHashes = /* @__PURE__ */ new Set();
|
|
1188
|
-
for (const key of await ds.list(`${sitemapUrlsPendingGenerationsPrefix(ctx)}/`)) {
|
|
1189
|
-
const feedpathHash = SITEMAP_URLS_PENDING_GENERATION_RE.exec(key)?.[1];
|
|
1190
|
-
if (feedpathHash) pendingFeedHashes.add(feedpathHash);
|
|
1191
|
-
}
|
|
1192
|
-
const repairedCheckpoints = /* @__PURE__ */ new Map();
|
|
1193
|
-
for (const feedpathHash of pendingFeedHashes) repairedCheckpoints.set(feedpathHash, await repairFeedProjection(ctx, feedpathHash));
|
|
1194
|
-
const present = /* @__PURE__ */ new Set();
|
|
1195
|
-
const [indexKeys, deltaKeys] = await Promise.all([ds.list(`${sitemapUrlsIndexPrefix(ctx)}/`), ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)]);
|
|
1196
|
-
let projectionManifest = await readProjectionManifest(ctx);
|
|
1197
|
-
await deleteExpiredProjectionDeltas(projectionManifest, deltaKeys, now());
|
|
1198
|
-
for (const feedpathHash of Object.keys(projectionManifest.feeds)) present.add(feedpathHash);
|
|
1199
|
-
for (const key of indexKeys) {
|
|
1200
|
-
const match = /\/by-feed\/([0-9a-f]+)\/index\.parquet$/.exec(key);
|
|
1201
|
-
if (match?.[1]) present.add(match[1]);
|
|
1202
|
-
}
|
|
1203
|
-
const deltasByFeed = /* @__PURE__ */ new Map();
|
|
1204
|
-
const activeDeltaKeys = selectSitemapProjectionFiles([], deltaKeys, projectionManifest).deltaKeys;
|
|
1205
|
-
for (const key of activeDeltaKeys) {
|
|
1206
|
-
const feedpathHash = parseSitemapUrlsDeltaKey(key)?.feedpathHash;
|
|
1207
|
-
if (!feedpathHash) continue;
|
|
1208
|
-
present.add(feedpathHash);
|
|
1209
|
-
const keys = deltasByFeed.get(feedpathHash) ?? [];
|
|
1210
|
-
keys.push(key);
|
|
1211
|
-
deltasByFeed.set(feedpathHash, keys);
|
|
1212
|
-
}
|
|
1213
|
-
let feedpathsPruned = 0;
|
|
1214
|
-
let urlsRemoved = 0;
|
|
1215
|
-
for (const feedpathHash of present) {
|
|
1216
|
-
if (liveHashes.has(feedpathHash)) continue;
|
|
1217
|
-
const checkpoint = repairedCheckpoints.has(feedpathHash) ? repairedCheckpoints.get(feedpathHash) : await repairFeedProjection(ctx, feedpathHash);
|
|
1218
|
-
const feedPosition = assertGenerationAccepted(generation, checkpoint, feedpathHash);
|
|
1219
|
-
if (feedPosition === "same" && checkpoint?._tag !== "reconcile") throw new Error(`sitemap generation conflict for ${feedpathHash}: expected reconcile event`);
|
|
1220
|
-
const [indexRows, deltaFiles] = await Promise.all([readOptional(ds, sitemapUrlsIndexKey(ctx, feedpathHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), readSitemapDeltaFiles(ds, deltasByFeed.get(feedpathHash) ?? [])]);
|
|
1221
|
-
const state = createSitemapUrlState(indexRows);
|
|
1222
|
-
applySitemapDeltaFiles(state, deltaFiles);
|
|
1223
|
-
const live = [...state.live.values()];
|
|
1224
|
-
const seedKey = sitemapUrlsEventSeedKey(ctx, feedpathHash);
|
|
1225
|
-
const seed = await readJson(seedKey);
|
|
1226
|
-
const isSeedGeneration = seed === void 0 || seed.generationId === generation.id;
|
|
1227
|
-
const seedRows = isSeedGeneration ? live.map((record) => ({
|
|
1228
|
-
feedpath: record.feedpath,
|
|
1229
|
-
feedpath_hash: feedpathHash,
|
|
1230
|
-
url_hash: record.urlHash,
|
|
1231
|
-
op: "added",
|
|
1232
|
-
loc: record.loc,
|
|
1233
|
-
lastmod: record.lastmod ?? null
|
|
1234
|
-
})) : [];
|
|
1235
|
-
const removalRows = live.map((record) => ({
|
|
1236
|
-
feedpath: record.feedpath,
|
|
1237
|
-
feedpath_hash: feedpathHash,
|
|
1238
|
-
url_hash: record.urlHash,
|
|
1239
|
-
op: "removed",
|
|
1240
|
-
loc: record.loc,
|
|
1241
|
-
lastmod: record.lastmod ?? null
|
|
1242
|
-
}));
|
|
1243
|
-
const proposedEvents = feedPosition === "newer" ? [...seedRows.map((row) => eventRow(generation, row, {
|
|
1244
|
-
sequence: 0,
|
|
1245
|
-
projectsState: false,
|
|
1246
|
-
seedGeneration: isSeedGeneration,
|
|
1247
|
-
generationKind: "reconcile"
|
|
1248
|
-
})), ...removalRows.map((row) => eventRow(generation, row, {
|
|
1249
|
-
sequence: seedRows.length > 0 ? 1 : 0,
|
|
1250
|
-
projectsState: true,
|
|
1251
|
-
seedGeneration: isSeedGeneration,
|
|
1252
|
-
generationKind: "reconcile"
|
|
1253
|
-
}))] : [];
|
|
1254
|
-
const persistedEvents = feedPosition === "newer" ? await ensureEvents(ctx, feedpathHash, generation, proposedEvents) : {
|
|
1255
|
-
rows: [],
|
|
1256
|
-
digest: checkpoint.eventDigest
|
|
1257
|
-
};
|
|
1258
|
-
if (isSeedGeneration && feedPosition === "newer") await writeJson(seedKey, {
|
|
1259
|
-
version: 1,
|
|
1260
|
-
generationId: generation.id,
|
|
1261
|
-
observedAt: generation.observedAt
|
|
1262
|
-
});
|
|
1263
|
-
const removedHashes = new Set(persistedEvents.rows.filter((row) => Boolean(row.projects_state) && String(row.op) === "removed").map((row) => String(row.url_hash)));
|
|
1264
|
-
const removedBeforeFeed = urlsRemoved;
|
|
1265
|
-
for (const record of live.filter((record) => removedHashes.has(record.urlHash))) {
|
|
1266
|
-
state.live.delete(record.urlHash);
|
|
1267
|
-
state.removed.set(record.urlHash, {
|
|
1268
|
-
...record,
|
|
1269
|
-
removedAt: generation.observedAt
|
|
1270
|
-
});
|
|
1271
|
-
urlsRemoved++;
|
|
1272
|
-
}
|
|
1273
|
-
if (live.length > 0 || deltaFiles.length > 0) projectionManifest = await publishCurrentProjection(ctx, feedpathHash, [...state.live.values(), ...state.removed.values()].sort((a, b) => a.urlHash.localeCompare(b.urlHash)), deltaFiles, projectionManifest, now());
|
|
1274
|
-
if (feedPosition === "newer") {
|
|
1275
|
-
const feedCheckpoint = persistedEvents.rows.length > 0 ? checkpointFromEvents(persistedEvents.rows, persistedEvents.digest) : {
|
|
1276
|
-
_tag: "reconcile",
|
|
1277
|
-
version: 1,
|
|
1278
|
-
generationId: generation.id,
|
|
1279
|
-
observedAt: generation.observedAt,
|
|
1280
|
-
eventDigest: persistedEvents.digest
|
|
1281
|
-
};
|
|
1282
|
-
await writeJson(sitemapUrlsGenerationKey(ctx, feedpathHash), feedCheckpoint);
|
|
1283
|
-
if (persistedEvents.rows.length > 0) await ds.delete([sitemapUrlsPendingGenerationKey(ctx, feedpathHash)]);
|
|
1284
|
-
}
|
|
1285
|
-
if (urlsRemoved > removedBeforeFeed) feedpathsPruned++;
|
|
1286
|
-
}
|
|
1287
|
-
await writeJson(sitemapUrlsReconcileGenerationKey(ctx), {
|
|
1288
|
-
version: 1,
|
|
1289
|
-
generationId: generation.id,
|
|
1290
|
-
observedAt: generation.observedAt,
|
|
1291
|
-
inputDigest
|
|
1292
|
-
});
|
|
1293
|
-
return {
|
|
1294
|
-
feedpathsPruned,
|
|
1295
|
-
urlsRemoved
|
|
1296
|
-
};
|
|
1297
|
-
});
|
|
1298
|
-
}
|
|
1299
|
-
};
|
|
1300
|
-
}
|
|
1301
|
-
function createIndexingMetadataStore(opts) {
|
|
1302
|
-
const ds = opts.dataSource;
|
|
1303
|
-
const hash = opts.hash ?? hashUrl;
|
|
1304
|
-
async function readIndex(key) {
|
|
1305
|
-
const bytes = await readOptional(ds, key);
|
|
1306
|
-
if (bytes === void 0) return {
|
|
1307
|
-
version: 1,
|
|
1308
|
-
records: {}
|
|
1309
|
-
};
|
|
1310
|
-
return JSON.parse(new TextDecoder().decode(bytes));
|
|
1311
|
-
}
|
|
1312
|
-
return {
|
|
1313
|
-
async writeBatch(ctx, records) {
|
|
1314
|
-
if (records.length === 0) return;
|
|
1315
|
-
const key = indexingMetadataIndexKey(ctx);
|
|
1316
|
-
const index = await readIndex(key);
|
|
1317
|
-
for (const r of records) index.records[hash(r.url)] = r;
|
|
1318
|
-
await ds.write(key, encodeJsonBigintSafe(index));
|
|
1319
|
-
},
|
|
1320
|
-
async loadIndex(ctx) {
|
|
1321
|
-
return readIndex(indexingMetadataIndexKey(ctx));
|
|
1322
|
-
},
|
|
1323
|
-
async getLatest(ctx, url) {
|
|
1324
|
-
return (await readIndex(indexingMetadataIndexKey(ctx))).records[hash(url)];
|
|
1325
|
-
}
|
|
1326
|
-
};
|
|
1327
|
-
}
|
|
1328
|
-
function createEmptyTypesStore(opts) {
|
|
1329
|
-
const ds = opts.dataSource;
|
|
1330
|
-
const now = opts.now ?? (() => Date.now());
|
|
1331
|
-
async function readDoc(key) {
|
|
1332
|
-
const bytes = await readOptional(ds, key);
|
|
1333
|
-
if (bytes === void 0) return {
|
|
1334
|
-
version: 1,
|
|
1335
|
-
emptyTypes: [],
|
|
1336
|
-
markedAt: {}
|
|
1337
|
-
};
|
|
1338
|
-
return JSON.parse(new TextDecoder().decode(bytes));
|
|
1339
|
-
}
|
|
1340
|
-
async function writeDoc(key, doc) {
|
|
1341
|
-
await ds.write(key, encodeJsonBigintSafe(doc));
|
|
1342
|
-
}
|
|
1343
|
-
return {
|
|
1344
|
-
async load(ctx) {
|
|
1345
|
-
return readDoc(emptyTypesKey(ctx));
|
|
1346
|
-
},
|
|
1347
|
-
async mark(ctx, types, at) {
|
|
1348
|
-
if (types.length === 0) return readDoc(emptyTypesKey(ctx));
|
|
1349
|
-
const key = emptyTypesKey(ctx);
|
|
1350
|
-
const doc = await readDoc(key);
|
|
1351
|
-
const stamp = at ?? now();
|
|
1352
|
-
let changed = false;
|
|
1353
|
-
for (const t of types) {
|
|
1354
|
-
if (!doc.emptyTypes.includes(t)) {
|
|
1355
|
-
doc.emptyTypes.push(t);
|
|
1356
|
-
changed = true;
|
|
1357
|
-
}
|
|
1358
|
-
if (doc.markedAt[t] === void 0) {
|
|
1359
|
-
doc.markedAt[t] = stamp;
|
|
1360
|
-
changed = true;
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
if (changed) {
|
|
1364
|
-
doc.emptyTypes.sort();
|
|
1365
|
-
await writeDoc(key, doc);
|
|
1366
|
-
}
|
|
1367
|
-
return doc;
|
|
1368
|
-
},
|
|
1369
|
-
async clear(ctx, types) {
|
|
1370
|
-
if (types.length === 0) return readDoc(emptyTypesKey(ctx));
|
|
1371
|
-
const key = emptyTypesKey(ctx);
|
|
1372
|
-
const doc = await readDoc(key);
|
|
1373
|
-
const drop = new Set(types);
|
|
1374
|
-
const before = doc.emptyTypes.length;
|
|
1375
|
-
doc.emptyTypes = doc.emptyTypes.filter((t) => !drop.has(t));
|
|
1376
|
-
for (const t of drop) delete doc.markedAt[t];
|
|
1377
|
-
if (doc.emptyTypes.length !== before) await writeDoc(key, doc);
|
|
1378
|
-
return doc;
|
|
1379
|
-
}
|
|
1380
|
-
};
|
|
1381
|
-
}
|
|
2
|
+
import { createEmptyTypesStore } from "./entities/empty-types.mjs";
|
|
3
|
+
import { createIndexingMetadataStore } from "./entities/indexing-metadata.mjs";
|
|
4
|
+
import { INSPECTION_HISTORY_MAX_BYTES, createInspectionStore } from "./entities/inspection.mjs";
|
|
5
|
+
import { buildQueryDimRecords, createQueryDimStore } from "./entities/query-dim.mjs";
|
|
6
|
+
import { SITEMAP_PROJECTION_GRACE_MS, decodeSitemapProjectionManifest, emptySitemapProjectionManifest, parseSitemapProjectionManifest, selectSitemapProjectionFiles, withSitemapProjectionFeed } from "./entities/sitemap-projection.mjs";
|
|
7
|
+
import { createSitemapReadStore } from "./entities/sitemap.mjs";
|
|
8
|
+
import { createSitemapStore } from "./entities/sitemap-write.mjs";
|
|
1382
9
|
export { INSPECTION_HISTORY_MAX_BYTES, SITEMAP_PROJECTION_GRACE_MS, buildQueryDimRecords, createEmptyTypesStore, createIndexingMetadataStore, createInspectionStore, createQueryDimStore, createSitemapReadStore, createSitemapStore, decodeSitemapProjectionManifest, emptySitemapProjectionManifest, emptyTypesKey, hashSortedUrlList, hashUrl, hashUrlList, indexingMetadataIndexKey, inspectionBaseKey, inspectionEventKey, inspectionEventsPrefix, inspectionHistoryPrefix, inspectionHistoryShardKey, inspectionIndexKey, inspectionParquetKey, inspectionTransitionsMonthKey, inspectionTransitionsPrefix, parseSitemapProjectionManifest, parseSitemapUrlsDeltaKey, queryDimMetaKey, queryDimParquetKey, selectSitemapProjectionFiles, sitemapHistoryKey, sitemapIndexKey, sitemapUrlsDeltaKey, sitemapUrlsEventKey, sitemapUrlsEventSeedKey, sitemapUrlsEventsPrefix, sitemapUrlsGenerationKey, sitemapUrlsIndexKey, sitemapUrlsIndexPrefix, sitemapUrlsPendingGenerationKey, sitemapUrlsPendingGenerationsPrefix, sitemapUrlsPrefix, sitemapUrlsProjectionManifestKey, sitemapUrlsReconcileGenerationKey, withSitemapProjectionFeed };
|