@gscdump/engine 1.4.3 → 1.4.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/adapters/node-harness.d.mts +2 -1
- package/dist/adapters/node-harness.mjs +12 -1
- package/dist/entities.d.mts +57 -29
- package/dist/entities.mjs +698 -325
- package/dist/entity-keys.d.mts +18 -2
- package/dist/entity-keys.mjs +38 -3
- package/dist/rollups.d.mts +2 -2
- package/dist/rollups.mjs +71 -20
- package/dist/sitemap-projection.d.mts +20 -0
- package/dist/sitemap-projection.mjs +57 -0
- package/package.json +4 -4
package/dist/entities.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { emptyTypesKey, hashSortedUrlList, hashUrl, hashUrlList, indexingMetadataIndexKey, inspectionBaseKey, inspectionEventKey, inspectionEventsPrefix, inspectionHistoryPrefix, inspectionHistoryShardKey, inspectionIndexKey, inspectionParquetKey, queryDimMetaKey, queryDimParquetKey, sitemapHistoryKey, sitemapIndexKey, sitemapUrlsDeltaKey, sitemapUrlsIndexKey, sitemapUrlsIndexPrefix, sitemapUrlsPrefix } from "./entity-keys.mjs";
|
|
1
|
+
import { emptyTypesKey, hashSortedUrlList, hashUrl, hashUrlList, indexingMetadataIndexKey, inspectionBaseKey, inspectionEventKey, inspectionEventsPrefix, inspectionHistoryPrefix, inspectionHistoryShardKey, inspectionIndexKey, inspectionParquetKey, parseSitemapUrlsDeltaKey, queryDimMetaKey, queryDimParquetKey, sitemapHistoryKey, sitemapIndexKey, sitemapUrlsDeltaKey, sitemapUrlsEventKey, sitemapUrlsEventSeedKey, sitemapUrlsEventsPrefix, sitemapUrlsGenerationKey, sitemapUrlsIndexKey, sitemapUrlsIndexPrefix, sitemapUrlsPendingGenerationKey, sitemapUrlsPendingGenerationsPrefix, sitemapUrlsPrefix, sitemapUrlsProjectionManifestKey, sitemapUrlsReconcileGenerationKey } from "./entity-keys.mjs";
|
|
2
2
|
import { decodeParquetToRows, encodeRowsToParquetFlex } from "./adapters/hyparquet.mjs";
|
|
3
3
|
import { readOptional } from "./adapters/read-optional.mjs";
|
|
4
|
+
import { SITEMAP_PROJECTION_GRACE_MS, decodeSitemapProjectionManifest, emptySitemapProjectionManifest, parseSitemapProjectionManifest, selectSitemapProjectionFiles, withSitemapProjectionFeed } from "./sitemap-projection.mjs";
|
|
4
5
|
import { buildQueryDimRecords, createQueryDimStore } from "./query-dim.mjs";
|
|
5
6
|
import { encodeJsonBigintSafe } from "@gscdump/lakehouse/bigint";
|
|
6
7
|
const YEAR_MONTH_RE = /^(\d{4})-(\d{2})-/;
|
|
@@ -315,7 +316,8 @@ function createInspectionStore(opts) {
|
|
|
315
316
|
}
|
|
316
317
|
};
|
|
317
318
|
}
|
|
318
|
-
const
|
|
319
|
+
const SITEMAP_URLS_EVENT_PREFIX_RE = /\/urls\/events\/(\d{4}-\d{2}-\d{2})__[0-9a-f]+__\d+__[0-9a-f]+\.parquet$/;
|
|
320
|
+
const SITEMAP_URLS_PENDING_GENERATION_RE = /\/urls\/generations\/pending\/([0-9a-f]+)\.json$/;
|
|
319
321
|
const URLS_INDEX_COLUMNS = [
|
|
320
322
|
{
|
|
321
323
|
name: "feedpath",
|
|
@@ -393,6 +395,83 @@ const URLS_DELTA_COLUMNS = [
|
|
|
393
395
|
name: "at",
|
|
394
396
|
type: "BIGINT",
|
|
395
397
|
nullable: false
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
name: "generation_id",
|
|
401
|
+
type: "VARCHAR",
|
|
402
|
+
nullable: true
|
|
403
|
+
}
|
|
404
|
+
];
|
|
405
|
+
const URLS_EVENT_COLUMNS = [
|
|
406
|
+
{
|
|
407
|
+
name: "feedpath",
|
|
408
|
+
type: "VARCHAR",
|
|
409
|
+
nullable: false
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
name: "feedpath_hash",
|
|
413
|
+
type: "VARCHAR",
|
|
414
|
+
nullable: false
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
name: "url_hash",
|
|
418
|
+
type: "VARCHAR",
|
|
419
|
+
nullable: false
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
name: "op",
|
|
423
|
+
type: "VARCHAR",
|
|
424
|
+
nullable: false
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
name: "loc",
|
|
428
|
+
type: "VARCHAR",
|
|
429
|
+
nullable: false
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
name: "lastmod",
|
|
433
|
+
type: "VARCHAR",
|
|
434
|
+
nullable: true
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
name: "generation_id",
|
|
438
|
+
type: "VARCHAR",
|
|
439
|
+
nullable: false
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
name: "observed_at",
|
|
443
|
+
type: "BIGINT",
|
|
444
|
+
nullable: false
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
name: "sequence",
|
|
448
|
+
type: "INTEGER",
|
|
449
|
+
nullable: false
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
name: "projects_state",
|
|
453
|
+
type: "INTEGER",
|
|
454
|
+
nullable: false
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
name: "seed_generation",
|
|
458
|
+
type: "INTEGER",
|
|
459
|
+
nullable: false
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
name: "generation_kind",
|
|
463
|
+
type: "VARCHAR",
|
|
464
|
+
nullable: false
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
name: "content_hash",
|
|
468
|
+
type: "VARCHAR",
|
|
469
|
+
nullable: true
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
name: "input_count",
|
|
473
|
+
type: "INTEGER",
|
|
474
|
+
nullable: true
|
|
396
475
|
}
|
|
397
476
|
];
|
|
398
477
|
function rowToUrlRecord(row) {
|
|
@@ -419,44 +498,71 @@ function urlRecordToRow(r) {
|
|
|
419
498
|
removed_at: r.removedAt ?? null
|
|
420
499
|
};
|
|
421
500
|
}
|
|
422
|
-
function
|
|
423
|
-
|
|
501
|
+
function createSitemapUrlState(indexRows) {
|
|
502
|
+
const state = {
|
|
503
|
+
live: /* @__PURE__ */ new Map(),
|
|
504
|
+
removed: /* @__PURE__ */ new Map()
|
|
505
|
+
};
|
|
506
|
+
for (const row of indexRows) {
|
|
507
|
+
const record = rowToUrlRecord(row);
|
|
508
|
+
if (record.removedAt == null) state.live.set(record.urlHash, record);
|
|
509
|
+
else state.removed.set(record.urlHash, record);
|
|
510
|
+
}
|
|
511
|
+
return state;
|
|
424
512
|
}
|
|
425
|
-
function
|
|
513
|
+
function applySitemapDeltaRows(state, rows) {
|
|
514
|
+
for (const row of rows) {
|
|
515
|
+
const urlHash = String(row.url_hash);
|
|
516
|
+
const observedAt = Number(row.at);
|
|
517
|
+
const op = String(row.op);
|
|
518
|
+
if (op === "added") {
|
|
519
|
+
const previous = state.live.get(urlHash) ?? state.removed.get(urlHash);
|
|
520
|
+
state.removed.delete(urlHash);
|
|
521
|
+
state.live.set(urlHash, {
|
|
522
|
+
feedpath: String(row.feedpath),
|
|
523
|
+
feedpathHash: String(row.feedpath_hash),
|
|
524
|
+
urlHash,
|
|
525
|
+
loc: String(row.loc),
|
|
526
|
+
lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
|
|
527
|
+
firstSeenAt: previous?.firstSeenAt ?? observedAt,
|
|
528
|
+
lastSeenAt: observedAt
|
|
529
|
+
});
|
|
530
|
+
} else if (op === "removed") {
|
|
531
|
+
const previous = state.live.get(urlHash);
|
|
532
|
+
state.live.delete(urlHash);
|
|
533
|
+
if (previous) state.removed.set(urlHash, {
|
|
534
|
+
...previous,
|
|
535
|
+
removedAt: observedAt
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function applySitemapDeltaFiles(state, files) {
|
|
541
|
+
for (const file of files) applySitemapDeltaRows(state, file.rows);
|
|
542
|
+
}
|
|
543
|
+
function sortSitemapDeltaFiles(files) {
|
|
544
|
+
return files.sort((a, b) => a.key.localeCompare(b.key));
|
|
545
|
+
}
|
|
546
|
+
async function readSitemapDeltaFiles(ds, keys) {
|
|
547
|
+
return sortSitemapDeltaFiles((await mapEntityIo(keys, async (key) => {
|
|
548
|
+
const bytes = await readOptional(ds, key);
|
|
549
|
+
return bytes ? {
|
|
550
|
+
key,
|
|
551
|
+
rows: await decodeParquetToRows(bytes)
|
|
552
|
+
} : void 0;
|
|
553
|
+
})).filter((file) => file !== void 0));
|
|
554
|
+
}
|
|
555
|
+
function dateInRange(date, range) {
|
|
556
|
+
return (!range?.from || date >= range.from) && (!range?.to || date <= range.to);
|
|
557
|
+
}
|
|
558
|
+
function createSitemapReadStore(opts) {
|
|
426
559
|
const ds = opts.dataSource;
|
|
427
560
|
const hash = opts.hash ?? hashUrl;
|
|
428
|
-
const now = opts.now ?? (() => Date.now());
|
|
429
561
|
async function readJson(key) {
|
|
430
562
|
const bytes = await readOptional(ds, key);
|
|
431
|
-
|
|
432
|
-
return JSON.parse(new TextDecoder().decode(bytes));
|
|
433
|
-
}
|
|
434
|
-
async function writeJson(key, value) {
|
|
435
|
-
await ds.write(key, encodeJsonBigintSafe(value));
|
|
563
|
+
return bytes === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(bytes));
|
|
436
564
|
}
|
|
437
565
|
return {
|
|
438
|
-
async writeSnapshot(ctx, records) {
|
|
439
|
-
if (records.length === 0) return;
|
|
440
|
-
const indexKey = sitemapIndexKey(ctx);
|
|
441
|
-
const index = await readJson(indexKey) ?? {
|
|
442
|
-
version: 1,
|
|
443
|
-
records: {}
|
|
444
|
-
};
|
|
445
|
-
const stamp = now();
|
|
446
|
-
const historyDocs = /* @__PURE__ */ new Map();
|
|
447
|
-
for (const r of records) {
|
|
448
|
-
const h = hash(r.path);
|
|
449
|
-
index.records[h] = r;
|
|
450
|
-
historyDocs.set(sitemapHistoryKey(ctx, h, stamp), {
|
|
451
|
-
version: 1,
|
|
452
|
-
path: r.path,
|
|
453
|
-
capturedAt: r.capturedAt,
|
|
454
|
-
record: r
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
await mapEntityIo([...historyDocs], ([key, doc]) => writeJson(key, doc));
|
|
458
|
-
await writeJson(indexKey, index);
|
|
459
|
-
},
|
|
460
566
|
async loadIndex(ctx) {
|
|
461
567
|
return await readJson(sitemapIndexKey(ctx)) ?? {
|
|
462
568
|
version: 1,
|
|
@@ -466,322 +572,589 @@ function createSitemapStore(opts) {
|
|
|
466
572
|
async getLatest(ctx, path) {
|
|
467
573
|
return (await readJson(sitemapIndexKey(ctx)))?.records[hash(path)];
|
|
468
574
|
},
|
|
469
|
-
async
|
|
470
|
-
const
|
|
471
|
-
const
|
|
472
|
-
const
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
contentHash,
|
|
482
|
-
unchanged: true
|
|
483
|
-
};
|
|
484
|
-
}
|
|
485
|
-
const incomingByHash = /* @__PURE__ */ new Map();
|
|
486
|
-
for (const u of urls) incomingByHash.set(hash(u.loc), u);
|
|
487
|
-
const deltaRows = [];
|
|
488
|
-
let added = 0;
|
|
489
|
-
let removed = 0;
|
|
490
|
-
let kept = 0;
|
|
491
|
-
const date = isoDate(at);
|
|
492
|
-
for (const [urlHash, u] of incomingByHash) {
|
|
493
|
-
const prev = priorByHash.get(urlHash);
|
|
494
|
-
if (!prev || prev.removedAt != null) {
|
|
495
|
-
added++;
|
|
496
|
-
deltaRows.push({
|
|
497
|
-
feedpath,
|
|
498
|
-
feedpath_hash: fpHash,
|
|
499
|
-
url_hash: urlHash,
|
|
500
|
-
op: "added",
|
|
501
|
-
loc: u.loc,
|
|
502
|
-
lastmod: u.lastmod ?? null,
|
|
503
|
-
at
|
|
504
|
-
});
|
|
505
|
-
} else kept++;
|
|
506
|
-
}
|
|
507
|
-
for (const [urlHash, prev] of priorByHash) {
|
|
508
|
-
if (prev.removedAt != null) continue;
|
|
509
|
-
if (!incomingByHash.has(urlHash)) {
|
|
510
|
-
removed++;
|
|
511
|
-
deltaRows.push({
|
|
512
|
-
feedpath,
|
|
513
|
-
feedpath_hash: fpHash,
|
|
514
|
-
url_hash: urlHash,
|
|
515
|
-
op: "removed",
|
|
516
|
-
loc: prev.loc,
|
|
517
|
-
lastmod: prev.lastmod ?? null,
|
|
518
|
-
at
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
if (deltaRows.length > 0) {
|
|
523
|
-
const bytes = encodeRowsToParquetFlex(deltaRows, {
|
|
524
|
-
columns: URLS_DELTA_COLUMNS,
|
|
525
|
-
sortKey: ["url_hash"]
|
|
526
|
-
});
|
|
527
|
-
await ds.write(sitemapUrlsDeltaKey(ctx, fpHash, date), bytes);
|
|
528
|
-
}
|
|
529
|
-
return {
|
|
530
|
-
added,
|
|
531
|
-
removed,
|
|
532
|
-
kept,
|
|
533
|
-
contentHash,
|
|
534
|
-
unchanged: false
|
|
535
|
-
};
|
|
575
|
+
async *loadUrls(ctx, feedpath, loadOpts) {
|
|
576
|
+
const feedpathHash = hash(feedpath);
|
|
577
|
+
const listedDeltaKeys = (await ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)).filter((key) => parseSitemapUrlsDeltaKey(key)?.feedpathHash === feedpathHash);
|
|
578
|
+
const listedDeltaFiles = await readSitemapDeltaFiles(ds, listedDeltaKeys);
|
|
579
|
+
const manifestBytes = await readOptional(ds, sitemapUrlsProjectionManifestKey(ctx));
|
|
580
|
+
const manifest = manifestBytes ? decodeSitemapProjectionManifest(new TextDecoder().decode(manifestBytes)) : void 0;
|
|
581
|
+
const indexRows = await readOptional(ds, sitemapUrlsIndexKey(ctx, feedpathHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []);
|
|
582
|
+
const currentDeltaKeys = new Set(selectSitemapProjectionFiles([], listedDeltaKeys, manifest).deltaKeys);
|
|
583
|
+
const state = createSitemapUrlState(indexRows);
|
|
584
|
+
applySitemapDeltaFiles(state, listedDeltaFiles.filter((file) => currentDeltaKeys.has(file.key)));
|
|
585
|
+
for (const record of state.live.values()) yield record;
|
|
586
|
+
if (loadOpts?.includeRemoved) for (const record of state.removed.values()) yield record;
|
|
536
587
|
},
|
|
537
|
-
async *
|
|
538
|
-
const
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
return SITEMAP_URLS_DELTA_PREFIX_RE.exec(key)?.[2] === fpHash;
|
|
543
|
-
}).sort();
|
|
544
|
-
const live = /* @__PURE__ */ new Map();
|
|
545
|
-
const removedMap = /* @__PURE__ */ new Map();
|
|
546
|
-
for (const row of indexRows) {
|
|
547
|
-
const rec = rowToUrlRecord(row);
|
|
548
|
-
if (rec.removedAt != null) removedMap.set(rec.urlHash, rec);
|
|
549
|
-
else live.set(rec.urlHash, rec);
|
|
550
|
-
}
|
|
551
|
-
const deltas = await mapEntityIo(deltaKeys, async (key) => {
|
|
552
|
-
const dBytes = await readOptional(ds, key);
|
|
553
|
-
if (!dBytes) return [];
|
|
554
|
-
return decodeParquetToRows(dBytes);
|
|
588
|
+
async *loadDeltas(ctx, dateRange) {
|
|
589
|
+
const [listedKeys, manifestBytes] = await Promise.all([ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`), readOptional(ds, sitemapUrlsProjectionManifestKey(ctx))]);
|
|
590
|
+
const keys = selectSitemapProjectionFiles([], listedKeys, manifestBytes ? decodeSitemapProjectionManifest(new TextDecoder().decode(manifestBytes)) : void 0).deltaKeys.filter((key) => {
|
|
591
|
+
const parsed = parseSitemapUrlsDeltaKey(key);
|
|
592
|
+
return Boolean(parsed && dateInRange(parsed.date, dateRange));
|
|
555
593
|
});
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
firstSeenAt: prev?.firstSeenAt ?? at,
|
|
570
|
-
lastSeenAt: at
|
|
571
|
-
});
|
|
572
|
-
} else if (op === "removed") {
|
|
573
|
-
const prev = live.get(urlHash);
|
|
574
|
-
live.delete(urlHash);
|
|
575
|
-
if (prev) removedMap.set(urlHash, {
|
|
576
|
-
...prev,
|
|
577
|
-
removedAt: at
|
|
578
|
-
});
|
|
579
|
-
}
|
|
594
|
+
const files = await readSitemapDeltaFiles(ds, keys);
|
|
595
|
+
for (const file of files) for (const row of file.rows) {
|
|
596
|
+
const op = String(row.op);
|
|
597
|
+
if (op !== "added" && op !== "removed") continue;
|
|
598
|
+
yield {
|
|
599
|
+
feedpath: String(row.feedpath),
|
|
600
|
+
feedpathHash: String(row.feedpath_hash),
|
|
601
|
+
urlHash: String(row.url_hash),
|
|
602
|
+
op,
|
|
603
|
+
loc: String(row.loc),
|
|
604
|
+
lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
|
|
605
|
+
at: Number(row.at)
|
|
606
|
+
};
|
|
580
607
|
}
|
|
581
|
-
for (const rec of live.values()) yield rec;
|
|
582
|
-
if (includeRemoved) for (const rec of removedMap.values()) yield rec;
|
|
583
608
|
},
|
|
584
|
-
async *
|
|
585
|
-
const
|
|
586
|
-
const
|
|
587
|
-
const
|
|
609
|
+
async *loadEvents(ctx, dateRange) {
|
|
610
|
+
const listedEventKeys = await ds.list(`${sitemapUrlsEventsPrefix(ctx)}/`);
|
|
611
|
+
const pendingKeys = await ds.list(`${sitemapUrlsPendingGenerationsPrefix(ctx)}/`);
|
|
612
|
+
const pendingEvents = new Set((await mapEntityIo(pendingKeys, (key) => readJson(key))).filter((pending) => pending !== void 0).map((pending) => pending.eventKey));
|
|
613
|
+
const keys = listedEventKeys.filter((key) => {
|
|
614
|
+
const match = SITEMAP_URLS_EVENT_PREFIX_RE.exec(key);
|
|
615
|
+
return !pendingEvents.has(key) && Boolean(match?.[1] && dateInRange(match[1], dateRange));
|
|
616
|
+
}).sort();
|
|
588
617
|
for (const key of keys) {
|
|
589
|
-
const m = SITEMAP_URLS_DELTA_PREFIX_RE.exec(key);
|
|
590
|
-
if (!m) continue;
|
|
591
|
-
const date = m[1];
|
|
592
|
-
if (!date) continue;
|
|
593
|
-
if (from && date < from) continue;
|
|
594
|
-
if (to && date > to) continue;
|
|
595
618
|
const bytes = await readOptional(ds, key);
|
|
596
619
|
if (!bytes) continue;
|
|
597
620
|
const rows = await decodeParquetToRows(bytes);
|
|
598
|
-
|
|
599
|
-
|
|
621
|
+
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)));
|
|
622
|
+
for (const row of rows) {
|
|
623
|
+
const op = String(row.op);
|
|
600
624
|
if (op !== "added" && op !== "removed") continue;
|
|
601
625
|
yield {
|
|
602
|
-
feedpath: String(
|
|
603
|
-
feedpathHash: String(
|
|
604
|
-
urlHash: String(
|
|
626
|
+
feedpath: String(row.feedpath),
|
|
627
|
+
feedpathHash: String(row.feedpath_hash),
|
|
628
|
+
urlHash: String(row.url_hash),
|
|
605
629
|
op,
|
|
606
|
-
loc: String(
|
|
607
|
-
lastmod:
|
|
608
|
-
|
|
630
|
+
loc: String(row.loc),
|
|
631
|
+
lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
|
|
632
|
+
generationId: String(row.generation_id),
|
|
633
|
+
observedAt: Number(row.observed_at),
|
|
634
|
+
sequence: Number(row.sequence),
|
|
635
|
+
projectsState: Boolean(row.projects_state)
|
|
609
636
|
};
|
|
610
637
|
}
|
|
611
638
|
}
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
function createSitemapStore(opts) {
|
|
643
|
+
const ds = opts.dataSource;
|
|
644
|
+
const hash = opts.hash ?? hashUrl;
|
|
645
|
+
const now = opts.now ?? (() => Date.now());
|
|
646
|
+
const withMutation = opts.withMutation;
|
|
647
|
+
const readStore = createSitemapReadStore({
|
|
648
|
+
dataSource: ds,
|
|
649
|
+
hash
|
|
650
|
+
});
|
|
651
|
+
async function readJson(key) {
|
|
652
|
+
const bytes = await readOptional(ds, key);
|
|
653
|
+
return bytes === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(bytes));
|
|
654
|
+
}
|
|
655
|
+
function writeJson(key, value) {
|
|
656
|
+
return ds.write(key, encodeJsonBigintSafe(value));
|
|
657
|
+
}
|
|
658
|
+
async function readProjectionManifest(ctx) {
|
|
659
|
+
const bytes = await readOptional(ds, sitemapUrlsProjectionManifestKey(ctx));
|
|
660
|
+
return bytes ? decodeSitemapProjectionManifest(new TextDecoder().decode(bytes)) : emptySitemapProjectionManifest();
|
|
661
|
+
}
|
|
662
|
+
async function deleteExpiredProjectionDeltas(manifest, deltaKeys, currentTime) {
|
|
663
|
+
const expired = deltaKeys.filter((key) => {
|
|
664
|
+
const parsed = parseSitemapUrlsDeltaKey(key);
|
|
665
|
+
const feed = parsed ? manifest.feeds[parsed.feedpathHash] : void 0;
|
|
666
|
+
return Boolean(feed && key <= feed.compactedThrough && currentTime - feed.publishedAt >= 9e5);
|
|
667
|
+
});
|
|
668
|
+
if (expired.length > 0) await ds.delete(expired);
|
|
669
|
+
}
|
|
670
|
+
async function publishCurrentProjection(ctx, feedpathHash, rows, consumedDeltas, manifest, publishedAt) {
|
|
671
|
+
const bytes = encodeRowsToParquetFlex(rows.map(urlRecordToRow), {
|
|
672
|
+
columns: URLS_INDEX_COLUMNS,
|
|
673
|
+
sortKey: ["feedpath_hash", "url_hash"]
|
|
674
|
+
});
|
|
675
|
+
await ds.write(sitemapUrlsIndexKey(ctx, feedpathHash), bytes);
|
|
676
|
+
const compactedThrough = consumedDeltas.at(-1)?.key;
|
|
677
|
+
if (!compactedThrough) return manifest;
|
|
678
|
+
const next = withSitemapProjectionFeed(manifest, feedpathHash, {
|
|
679
|
+
compactedThrough,
|
|
680
|
+
publishedAt
|
|
681
|
+
});
|
|
682
|
+
await writeJson(sitemapUrlsProjectionManifestKey(ctx), next);
|
|
683
|
+
return next;
|
|
684
|
+
}
|
|
685
|
+
function normalizedEventRows(rows) {
|
|
686
|
+
return rows.map((row) => ({
|
|
687
|
+
feedpath: String(row.feedpath),
|
|
688
|
+
feedpathHash: String(row.feedpath_hash),
|
|
689
|
+
urlHash: String(row.url_hash),
|
|
690
|
+
op: String(row.op),
|
|
691
|
+
loc: String(row.loc),
|
|
692
|
+
lastmod: row.lastmod == null ? null : String(row.lastmod),
|
|
693
|
+
generationId: String(row.generation_id),
|
|
694
|
+
observedAt: Number(row.observed_at),
|
|
695
|
+
sequence: Number(row.sequence),
|
|
696
|
+
projectsState: Boolean(row.projects_state),
|
|
697
|
+
seedGeneration: Boolean(row.seed_generation),
|
|
698
|
+
generationKind: String(row.generation_kind),
|
|
699
|
+
contentHash: row.content_hash == null ? null : String(row.content_hash),
|
|
700
|
+
inputCount: row.input_count == null ? null : Number(row.input_count)
|
|
701
|
+
})).sort((a, b) => a.observedAt - b.observedAt || a.sequence - b.sequence || a.urlHash.localeCompare(b.urlHash) || a.op.localeCompare(b.op));
|
|
702
|
+
}
|
|
703
|
+
function eventDigest(rows) {
|
|
704
|
+
return hashUrl(JSON.stringify(normalizedEventRows(rows)));
|
|
705
|
+
}
|
|
706
|
+
function assertGenerationAccepted(generation, checkpoint, scope) {
|
|
707
|
+
if (!checkpoint) return "newer";
|
|
708
|
+
if (generation.id === checkpoint.generationId) {
|
|
709
|
+
if (generation.observedAt === checkpoint.observedAt) return "same";
|
|
710
|
+
throw new Error(`sitemap generation conflict for ${scope}: generation ${generation.id} changed observedAt`);
|
|
711
|
+
}
|
|
712
|
+
if (generation.observedAt > checkpoint.observedAt) return "newer";
|
|
713
|
+
const reason = generation.observedAt === checkpoint.observedAt ? "ambiguous timestamp" : "stale generation";
|
|
714
|
+
throw new Error(`sitemap generation conflict for ${scope}: ${reason}`);
|
|
715
|
+
}
|
|
716
|
+
async function ensureEvents(ctx, feedpathHash, generation, rows) {
|
|
717
|
+
const expectedDigest = eventDigest(rows);
|
|
718
|
+
if (rows.length === 0) return {
|
|
719
|
+
rows: [],
|
|
720
|
+
digest: expectedDigest
|
|
721
|
+
};
|
|
722
|
+
const key = sitemapUrlsEventKey(ctx, feedpathHash, generation);
|
|
723
|
+
await writeJson(sitemapUrlsPendingGenerationKey(ctx, feedpathHash), {
|
|
724
|
+
version: 1,
|
|
725
|
+
generationId: generation.id,
|
|
726
|
+
observedAt: generation.observedAt,
|
|
727
|
+
eventKey: key,
|
|
728
|
+
eventDigest: expectedDigest
|
|
729
|
+
});
|
|
730
|
+
const existing = await readOptional(ds, key);
|
|
731
|
+
if (existing) {
|
|
732
|
+
const existingRows = await decodeParquetToRows(existing);
|
|
733
|
+
const existingDigest = eventDigest(existingRows);
|
|
734
|
+
if (existingDigest !== expectedDigest) throw new Error(`sitemap generation conflict for ${feedpathHash}: immutable event digest changed`);
|
|
735
|
+
return {
|
|
736
|
+
rows: existingRows,
|
|
737
|
+
digest: existingDigest
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
const bytes = encodeRowsToParquetFlex(rows, {
|
|
741
|
+
columns: URLS_EVENT_COLUMNS,
|
|
742
|
+
sortKey: ["sequence", "url_hash"]
|
|
743
|
+
});
|
|
744
|
+
await ds.write(key, bytes);
|
|
745
|
+
return {
|
|
746
|
+
rows: [...rows],
|
|
747
|
+
digest: expectedDigest
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
function eventRow(generation, row, metadata) {
|
|
751
|
+
return {
|
|
752
|
+
feedpath: row.feedpath,
|
|
753
|
+
feedpath_hash: row.feedpath_hash,
|
|
754
|
+
url_hash: row.url_hash,
|
|
755
|
+
op: row.op,
|
|
756
|
+
loc: row.loc,
|
|
757
|
+
lastmod: row.lastmod,
|
|
758
|
+
generation_id: generation.id,
|
|
759
|
+
observed_at: generation.observedAt,
|
|
760
|
+
sequence: metadata.sequence,
|
|
761
|
+
projects_state: metadata.projectsState ? 1 : 0,
|
|
762
|
+
seed_generation: metadata.seedGeneration ? 1 : 0,
|
|
763
|
+
generation_kind: metadata.generationKind,
|
|
764
|
+
content_hash: metadata.contentHash ?? null,
|
|
765
|
+
input_count: metadata.inputCount ?? null
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
function stateRowsFromEvents(rows) {
|
|
769
|
+
return rows.filter((row) => Boolean(row.projects_state)).map((row) => ({
|
|
770
|
+
feedpath: row.feedpath,
|
|
771
|
+
feedpath_hash: row.feedpath_hash,
|
|
772
|
+
url_hash: row.url_hash,
|
|
773
|
+
op: row.op,
|
|
774
|
+
loc: row.loc,
|
|
775
|
+
lastmod: row.lastmod,
|
|
776
|
+
at: row.observed_at,
|
|
777
|
+
generation_id: row.generation_id
|
|
778
|
+
}));
|
|
779
|
+
}
|
|
780
|
+
function checkpointFromEvents(rows, digest) {
|
|
781
|
+
const first = rows[0];
|
|
782
|
+
if (!first) throw new Error("cannot checkpoint an empty sitemap event file");
|
|
783
|
+
const generationId = String(first.generation_id);
|
|
784
|
+
const observedAt = Number(first.observed_at);
|
|
785
|
+
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");
|
|
786
|
+
if (String(first.generation_kind) === "snapshot") {
|
|
787
|
+
const contentHash = String(first.content_hash);
|
|
788
|
+
const inputCount = Number(first.input_count);
|
|
789
|
+
const stateRows = stateRowsFromEvents(rows);
|
|
790
|
+
const added = stateRows.filter((row) => String(row.op) === "added").length;
|
|
791
|
+
return {
|
|
792
|
+
_tag: "snapshot",
|
|
793
|
+
version: 1,
|
|
794
|
+
generationId,
|
|
795
|
+
observedAt,
|
|
796
|
+
eventDigest: digest,
|
|
797
|
+
result: {
|
|
798
|
+
added,
|
|
799
|
+
removed: stateRows.filter((row) => String(row.op) === "removed").length,
|
|
800
|
+
kept: Math.max(0, inputCount - added),
|
|
801
|
+
contentHash,
|
|
802
|
+
unchanged: stateRows.length === 0
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
return {
|
|
807
|
+
_tag: "reconcile",
|
|
808
|
+
version: 1,
|
|
809
|
+
generationId,
|
|
810
|
+
observedAt,
|
|
811
|
+
eventDigest: digest
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
async function projectEvents(ctx, feedpathHash, rows, digest) {
|
|
815
|
+
const checkpoint = checkpointFromEvents(rows, digest);
|
|
816
|
+
const generation = {
|
|
817
|
+
id: checkpoint.generationId,
|
|
818
|
+
observedAt: checkpoint.observedAt
|
|
819
|
+
};
|
|
820
|
+
const stateRows = stateRowsFromEvents(rows);
|
|
821
|
+
if (stateRows.length > 0) {
|
|
822
|
+
const bytes = encodeRowsToParquetFlex(stateRows, {
|
|
823
|
+
columns: URLS_DELTA_COLUMNS,
|
|
824
|
+
sortKey: ["url_hash"]
|
|
825
|
+
});
|
|
826
|
+
await ds.write(sitemapUrlsDeltaKey(ctx, feedpathHash, generation), bytes);
|
|
827
|
+
}
|
|
828
|
+
if (rows.some((row) => Boolean(row.seed_generation))) await writeJson(sitemapUrlsEventSeedKey(ctx, feedpathHash), {
|
|
829
|
+
version: 1,
|
|
830
|
+
generationId: checkpoint.generationId,
|
|
831
|
+
observedAt: checkpoint.observedAt
|
|
832
|
+
});
|
|
833
|
+
await writeJson(sitemapUrlsGenerationKey(ctx, feedpathHash), checkpoint);
|
|
834
|
+
return checkpoint;
|
|
835
|
+
}
|
|
836
|
+
async function repairFeedProjection(ctx, feedpathHash) {
|
|
837
|
+
const pendingKey = sitemapUrlsPendingGenerationKey(ctx, feedpathHash);
|
|
838
|
+
const [checkpoint, pending] = await Promise.all([readJson(sitemapUrlsGenerationKey(ctx, feedpathHash)), readJson(pendingKey)]);
|
|
839
|
+
if (!pending) return checkpoint;
|
|
840
|
+
const pendingGeneration = {
|
|
841
|
+
_tag: "complete",
|
|
842
|
+
id: pending.generationId,
|
|
843
|
+
observedAt: pending.observedAt
|
|
844
|
+
};
|
|
845
|
+
if (checkpoint?.generationId === pending.generationId && checkpoint.observedAt !== pending.observedAt) throw new Error(`sitemap generation conflict for ${feedpathHash}: generation ${pending.generationId} changed observedAt`);
|
|
846
|
+
if (checkpoint && pending.observedAt < checkpoint.observedAt) {
|
|
847
|
+
await ds.delete([pendingKey]);
|
|
848
|
+
return checkpoint;
|
|
849
|
+
}
|
|
850
|
+
const position = assertGenerationAccepted(pendingGeneration, checkpoint, feedpathHash);
|
|
851
|
+
const eventBytes = await readOptional(ds, pending.eventKey);
|
|
852
|
+
if (!eventBytes) {
|
|
853
|
+
await ds.delete([pendingKey]);
|
|
854
|
+
return checkpoint;
|
|
855
|
+
}
|
|
856
|
+
const rows = await decodeParquetToRows(eventBytes);
|
|
857
|
+
const digest = eventDigest(rows);
|
|
858
|
+
if (digest !== pending.eventDigest) throw new Error(`sitemap generation conflict for ${feedpathHash}: pending event digest changed`);
|
|
859
|
+
if (position === "same") {
|
|
860
|
+
if (checkpoint?.eventDigest !== digest) throw new Error(`sitemap generation conflict for ${feedpathHash}: checkpoint digest changed`);
|
|
861
|
+
await ds.delete([pendingKey]);
|
|
862
|
+
return checkpoint;
|
|
863
|
+
}
|
|
864
|
+
const repaired = await projectEvents(ctx, feedpathHash, rows, digest);
|
|
865
|
+
await ds.delete([pendingKey]);
|
|
866
|
+
return repaired;
|
|
867
|
+
}
|
|
868
|
+
return {
|
|
869
|
+
...readStore,
|
|
870
|
+
writeSnapshot(ctx, records) {
|
|
871
|
+
return withMutation(ctx, async () => {
|
|
872
|
+
if (records.length === 0) return;
|
|
873
|
+
const indexKey = sitemapIndexKey(ctx);
|
|
874
|
+
const index = await readJson(indexKey) ?? {
|
|
875
|
+
version: 1,
|
|
876
|
+
records: {}
|
|
877
|
+
};
|
|
878
|
+
const stamp = now();
|
|
879
|
+
const historyDocs = /* @__PURE__ */ new Map();
|
|
880
|
+
for (const record of records) {
|
|
881
|
+
const feedpathHash = hash(record.path);
|
|
882
|
+
index.records[feedpathHash] = record;
|
|
883
|
+
historyDocs.set(sitemapHistoryKey(ctx, feedpathHash, stamp), {
|
|
884
|
+
version: 1,
|
|
885
|
+
path: record.path,
|
|
886
|
+
capturedAt: record.capturedAt,
|
|
887
|
+
record
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
await mapEntityIo([...historyDocs], ([key, doc]) => writeJson(key, doc));
|
|
891
|
+
await writeJson(indexKey, index);
|
|
892
|
+
});
|
|
612
893
|
},
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
if (!m) continue;
|
|
622
|
-
const feedpathHash = m[2];
|
|
623
|
-
if (!feedpathHash) continue;
|
|
624
|
-
const list = deltasByFeed.get(feedpathHash) ?? [];
|
|
625
|
-
list.push(key);
|
|
626
|
-
deltasByFeed.set(feedpathHash, list);
|
|
627
|
-
}
|
|
628
|
-
const totalFeedpaths = deltasByFeed.size;
|
|
629
|
-
let compactedFeedpaths = 0;
|
|
630
|
-
for (const [fpHash, feedDeltaKeys] of deltasByFeed) {
|
|
631
|
-
if (compactedFeedpaths > 0 && (compactedFeedpaths >= maxFeedpaths || now() - startedAt >= deadlineMs)) break;
|
|
632
|
-
const indexKey = sitemapUrlsIndexKey(ctx, fpHash);
|
|
633
|
-
const [indexRows, deltaFiles] = await Promise.all([readOptional(ds, indexKey).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), mapEntityIo(feedDeltaKeys.sort(), async (key) => {
|
|
634
|
-
const bytes = await readOptional(ds, key);
|
|
635
|
-
if (!bytes) return void 0;
|
|
636
|
-
return {
|
|
637
|
-
key,
|
|
638
|
-
rows: await decodeParquetToRows(bytes)
|
|
639
|
-
};
|
|
640
|
-
})]);
|
|
641
|
-
const live = /* @__PURE__ */ new Map();
|
|
642
|
-
const removed = /* @__PURE__ */ new Map();
|
|
643
|
-
for (const row of indexRows) {
|
|
644
|
-
const rec = rowToUrlRecord(row);
|
|
645
|
-
if (rec.removedAt != null) removed.set(rec.urlHash, rec);
|
|
646
|
-
else live.set(rec.urlHash, rec);
|
|
894
|
+
snapshotUrls(ctx, generation, feedpath, urls) {
|
|
895
|
+
return withMutation(ctx, async () => {
|
|
896
|
+
const feedpathHash = hash(feedpath);
|
|
897
|
+
const contentHash = hashUrlList(urls);
|
|
898
|
+
const checkpoint = await repairFeedProjection(ctx, feedpathHash);
|
|
899
|
+
if (assertGenerationAccepted(generation, checkpoint, feedpathHash) === "same") {
|
|
900
|
+
if (checkpoint?._tag !== "snapshot" || checkpoint.result.contentHash !== contentHash) throw new Error(`sitemap generation conflict for ${feedpathHash}: snapshot input changed`);
|
|
901
|
+
return checkpoint.result;
|
|
647
902
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
...prev,
|
|
674
|
-
removedAt: at
|
|
675
|
-
});
|
|
676
|
-
}
|
|
677
|
-
}
|
|
903
|
+
if (assertGenerationAccepted(generation, await readJson(sitemapUrlsReconcileGenerationKey(ctx)), "site reconciliation") === "same") throw new Error("sitemap generation conflict: site generation was already reconciled");
|
|
904
|
+
const priorByHash = /* @__PURE__ */ new Map();
|
|
905
|
+
for await (const record of readStore.loadUrls(ctx, feedpath, { includeRemoved: true })) priorByHash.set(record.urlHash, record);
|
|
906
|
+
const livePrior = [...priorByHash.values()].filter((record) => record.removedAt == null);
|
|
907
|
+
const incomingByHash = /* @__PURE__ */ new Map();
|
|
908
|
+
for (const url of urls) incomingByHash.set(hash(url.loc), url);
|
|
909
|
+
const deltaRows = [];
|
|
910
|
+
let added = 0;
|
|
911
|
+
let removed = 0;
|
|
912
|
+
let kept = 0;
|
|
913
|
+
for (const [urlHash, url] of incomingByHash) {
|
|
914
|
+
const previous = priorByHash.get(urlHash);
|
|
915
|
+
if (!previous || previous.removedAt != null) {
|
|
916
|
+
added++;
|
|
917
|
+
deltaRows.push({
|
|
918
|
+
feedpath,
|
|
919
|
+
feedpath_hash: feedpathHash,
|
|
920
|
+
url_hash: urlHash,
|
|
921
|
+
op: "added",
|
|
922
|
+
loc: url.loc,
|
|
923
|
+
lastmod: url.lastmod ?? null,
|
|
924
|
+
at: generation.observedAt,
|
|
925
|
+
generation_id: generation.id
|
|
926
|
+
});
|
|
927
|
+
} else kept++;
|
|
678
928
|
}
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
929
|
+
for (const [urlHash, previous] of priorByHash) if (previous.removedAt == null && !incomingByHash.has(urlHash)) {
|
|
930
|
+
removed++;
|
|
931
|
+
deltaRows.push({
|
|
932
|
+
feedpath,
|
|
933
|
+
feedpath_hash: feedpathHash,
|
|
934
|
+
url_hash: urlHash,
|
|
935
|
+
op: "removed",
|
|
936
|
+
loc: previous.loc,
|
|
937
|
+
lastmod: previous.lastmod ?? null,
|
|
938
|
+
at: generation.observedAt,
|
|
939
|
+
generation_id: generation.id
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
const seedKey = sitemapUrlsEventSeedKey(ctx, feedpathHash);
|
|
943
|
+
const seed = await readJson(seedKey);
|
|
944
|
+
const isSeedGeneration = seed === void 0 || seed.generationId === generation.id;
|
|
945
|
+
const seedRows = isSeedGeneration ? livePrior.map((record) => ({
|
|
946
|
+
feedpath,
|
|
947
|
+
feedpath_hash: feedpathHash,
|
|
948
|
+
url_hash: record.urlHash,
|
|
949
|
+
op: "added",
|
|
950
|
+
loc: record.loc,
|
|
951
|
+
lastmod: record.lastmod ?? null
|
|
952
|
+
})) : [];
|
|
953
|
+
const actualSequence = seedRows.length > 0 ? 1 : 0;
|
|
954
|
+
const inputCount = incomingByHash.size;
|
|
955
|
+
const persistedEvents = await ensureEvents(ctx, feedpathHash, generation, [...seedRows.map((row) => eventRow(generation, row, {
|
|
956
|
+
sequence: 0,
|
|
957
|
+
projectsState: false,
|
|
958
|
+
seedGeneration: isSeedGeneration,
|
|
959
|
+
generationKind: "snapshot",
|
|
960
|
+
contentHash,
|
|
961
|
+
inputCount
|
|
962
|
+
})), ...deltaRows.map((row) => eventRow(generation, row, {
|
|
963
|
+
sequence: actualSequence,
|
|
964
|
+
projectsState: true,
|
|
965
|
+
seedGeneration: isSeedGeneration,
|
|
966
|
+
generationKind: "snapshot",
|
|
967
|
+
contentHash,
|
|
968
|
+
inputCount
|
|
969
|
+
}))]);
|
|
970
|
+
const result = {
|
|
971
|
+
added,
|
|
972
|
+
removed,
|
|
973
|
+
kept,
|
|
974
|
+
contentHash,
|
|
975
|
+
unchanged: deltaRows.length === 0
|
|
976
|
+
};
|
|
977
|
+
if (persistedEvents.rows.length > 0) {
|
|
978
|
+
if ((await projectEvents(ctx, feedpathHash, persistedEvents.rows, persistedEvents.digest))._tag !== "snapshot") throw new Error(`sitemap generation conflict for ${feedpathHash}: expected snapshot event`);
|
|
979
|
+
await ds.delete([sitemapUrlsPendingGenerationKey(ctx, feedpathHash)]);
|
|
980
|
+
} else {
|
|
981
|
+
if (isSeedGeneration) await writeJson(seedKey, {
|
|
982
|
+
version: 1,
|
|
983
|
+
generationId: generation.id,
|
|
984
|
+
observedAt: generation.observedAt
|
|
985
|
+
});
|
|
986
|
+
await writeJson(sitemapUrlsGenerationKey(ctx, feedpathHash), {
|
|
987
|
+
_tag: "snapshot",
|
|
988
|
+
version: 1,
|
|
989
|
+
generationId: generation.id,
|
|
990
|
+
observedAt: generation.observedAt,
|
|
991
|
+
eventDigest: persistedEvents.digest,
|
|
992
|
+
result
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
return result;
|
|
996
|
+
});
|
|
693
997
|
},
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
const
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
const [
|
|
718
|
-
|
|
719
|
-
|
|
998
|
+
compactUrls(ctx, compactOpts = {}) {
|
|
999
|
+
return withMutation(ctx, async () => {
|
|
1000
|
+
const startedAt = now();
|
|
1001
|
+
const deadlineMs = compactOpts.deadlineMs ?? Number.POSITIVE_INFINITY;
|
|
1002
|
+
const maxFeedpaths = compactOpts.maxFeedpaths ?? Number.POSITIVE_INFINITY;
|
|
1003
|
+
for (const key of await ds.list(`${sitemapUrlsPendingGenerationsPrefix(ctx)}/`)) {
|
|
1004
|
+
const feedpathHash = SITEMAP_URLS_PENDING_GENERATION_RE.exec(key)?.[1];
|
|
1005
|
+
if (feedpathHash) await repairFeedProjection(ctx, feedpathHash);
|
|
1006
|
+
}
|
|
1007
|
+
const deltaKeys = await ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`);
|
|
1008
|
+
let projectionManifest = await readProjectionManifest(ctx);
|
|
1009
|
+
await deleteExpiredProjectionDeltas(projectionManifest, deltaKeys, startedAt);
|
|
1010
|
+
const activeDeltaKeys = selectSitemapProjectionFiles([], deltaKeys, projectionManifest).deltaKeys;
|
|
1011
|
+
const deltasByFeed = /* @__PURE__ */ new Map();
|
|
1012
|
+
for (const key of activeDeltaKeys) {
|
|
1013
|
+
const feedpathHash = parseSitemapUrlsDeltaKey(key)?.feedpathHash;
|
|
1014
|
+
if (!feedpathHash) continue;
|
|
1015
|
+
const keys = deltasByFeed.get(feedpathHash) ?? [];
|
|
1016
|
+
keys.push(key);
|
|
1017
|
+
deltasByFeed.set(feedpathHash, keys);
|
|
1018
|
+
}
|
|
1019
|
+
const totalFeedpaths = deltasByFeed.size;
|
|
1020
|
+
let compactedFeedpaths = 0;
|
|
1021
|
+
for (const [feedpathHash, feedDeltaKeys] of deltasByFeed) {
|
|
1022
|
+
if (compactedFeedpaths > 0 && (compactedFeedpaths >= maxFeedpaths || now() - startedAt >= deadlineMs)) break;
|
|
1023
|
+
const [indexRows, deltaFiles] = await Promise.all([readOptional(ds, sitemapUrlsIndexKey(ctx, feedpathHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), readSitemapDeltaFiles(ds, feedDeltaKeys)]);
|
|
1024
|
+
const state = createSitemapUrlState(indexRows);
|
|
1025
|
+
applySitemapDeltaFiles(state, deltaFiles);
|
|
1026
|
+
projectionManifest = await publishCurrentProjection(ctx, feedpathHash, [...state.live.values(), ...state.removed.values()].sort((a, b) => a.urlHash.localeCompare(b.urlHash)), deltaFiles, projectionManifest, now());
|
|
1027
|
+
compactedFeedpaths++;
|
|
1028
|
+
}
|
|
1029
|
+
return {
|
|
1030
|
+
compactedFeedpaths,
|
|
1031
|
+
remainingFeedpaths: totalFeedpaths - compactedFeedpaths
|
|
1032
|
+
};
|
|
1033
|
+
});
|
|
1034
|
+
},
|
|
1035
|
+
reconcile(ctx, generation, { liveFeedpaths }) {
|
|
1036
|
+
return withMutation(ctx, async () => {
|
|
1037
|
+
const liveHashes = new Set(liveFeedpaths.map((feedpath) => hash(feedpath)));
|
|
1038
|
+
const inputDigest = hashSortedUrlList([...liveHashes]);
|
|
1039
|
+
const siteCheckpoint = await readJson(sitemapUrlsReconcileGenerationKey(ctx));
|
|
1040
|
+
if (assertGenerationAccepted(generation, siteCheckpoint, "site reconciliation") === "same") {
|
|
1041
|
+
if (siteCheckpoint?.inputDigest !== inputDigest) throw new Error("sitemap generation conflict: reconcile input changed");
|
|
720
1042
|
return {
|
|
721
|
-
|
|
722
|
-
|
|
1043
|
+
feedpathsPruned: 0,
|
|
1044
|
+
urlsRemoved: 0
|
|
723
1045
|
};
|
|
724
|
-
})]);
|
|
725
|
-
const live = /* @__PURE__ */ new Map();
|
|
726
|
-
const removed = /* @__PURE__ */ new Map();
|
|
727
|
-
for (const row of indexRows) {
|
|
728
|
-
const r = rowToUrlRecord(row);
|
|
729
|
-
if (r.removedAt != null) removed.set(r.urlHash, r);
|
|
730
|
-
else live.set(r.urlHash, r);
|
|
731
1046
|
}
|
|
732
|
-
const
|
|
733
|
-
for (const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
loc: String(r.loc),
|
|
748
|
-
lastmod: r.lastmod == null ? void 0 : String(r.lastmod),
|
|
749
|
-
firstSeenAt: prev?.firstSeenAt ?? dat,
|
|
750
|
-
lastSeenAt: dat
|
|
751
|
-
});
|
|
752
|
-
} else if (String(r.op) === "removed") {
|
|
753
|
-
const prev = live.get(urlHash);
|
|
754
|
-
live.delete(urlHash);
|
|
755
|
-
if (prev) removed.set(urlHash, {
|
|
756
|
-
...prev,
|
|
757
|
-
removedAt: dat
|
|
758
|
-
});
|
|
759
|
-
}
|
|
760
|
-
}
|
|
1047
|
+
const pendingFeedHashes = /* @__PURE__ */ new Set();
|
|
1048
|
+
for (const key of await ds.list(`${sitemapUrlsPendingGenerationsPrefix(ctx)}/`)) {
|
|
1049
|
+
const feedpathHash = SITEMAP_URLS_PENDING_GENERATION_RE.exec(key)?.[1];
|
|
1050
|
+
if (feedpathHash) pendingFeedHashes.add(feedpathHash);
|
|
1051
|
+
}
|
|
1052
|
+
const repairedCheckpoints = /* @__PURE__ */ new Map();
|
|
1053
|
+
for (const feedpathHash of pendingFeedHashes) repairedCheckpoints.set(feedpathHash, await repairFeedProjection(ctx, feedpathHash));
|
|
1054
|
+
const present = /* @__PURE__ */ new Set();
|
|
1055
|
+
const [indexKeys, deltaKeys] = await Promise.all([ds.list(`${sitemapUrlsIndexPrefix(ctx)}/`), ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)]);
|
|
1056
|
+
let projectionManifest = await readProjectionManifest(ctx);
|
|
1057
|
+
await deleteExpiredProjectionDeltas(projectionManifest, deltaKeys, now());
|
|
1058
|
+
for (const feedpathHash of Object.keys(projectionManifest.feeds)) present.add(feedpathHash);
|
|
1059
|
+
for (const key of indexKeys) {
|
|
1060
|
+
const match = /\/by-feed\/([0-9a-f]+)\/index\.parquet$/.exec(key);
|
|
1061
|
+
if (match?.[1]) present.add(match[1]);
|
|
761
1062
|
}
|
|
762
|
-
const
|
|
763
|
-
|
|
764
|
-
for (const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
1063
|
+
const deltasByFeed = /* @__PURE__ */ new Map();
|
|
1064
|
+
const activeDeltaKeys = selectSitemapProjectionFiles([], deltaKeys, projectionManifest).deltaKeys;
|
|
1065
|
+
for (const key of activeDeltaKeys) {
|
|
1066
|
+
const feedpathHash = parseSitemapUrlsDeltaKey(key)?.feedpathHash;
|
|
1067
|
+
if (!feedpathHash) continue;
|
|
1068
|
+
present.add(feedpathHash);
|
|
1069
|
+
const keys = deltasByFeed.get(feedpathHash) ?? [];
|
|
1070
|
+
keys.push(key);
|
|
1071
|
+
deltasByFeed.set(feedpathHash, keys);
|
|
1072
|
+
}
|
|
1073
|
+
let feedpathsPruned = 0;
|
|
1074
|
+
let urlsRemoved = 0;
|
|
1075
|
+
for (const feedpathHash of present) {
|
|
1076
|
+
if (liveHashes.has(feedpathHash)) continue;
|
|
1077
|
+
const checkpoint = repairedCheckpoints.has(feedpathHash) ? repairedCheckpoints.get(feedpathHash) : await repairFeedProjection(ctx, feedpathHash);
|
|
1078
|
+
const feedPosition = assertGenerationAccepted(generation, checkpoint, feedpathHash);
|
|
1079
|
+
if (feedPosition === "same" && checkpoint?._tag !== "reconcile") throw new Error(`sitemap generation conflict for ${feedpathHash}: expected reconcile event`);
|
|
1080
|
+
const [indexRows, deltaFiles] = await Promise.all([readOptional(ds, sitemapUrlsIndexKey(ctx, feedpathHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), readSitemapDeltaFiles(ds, deltasByFeed.get(feedpathHash) ?? [])]);
|
|
1081
|
+
const state = createSitemapUrlState(indexRows);
|
|
1082
|
+
applySitemapDeltaFiles(state, deltaFiles);
|
|
1083
|
+
const live = [...state.live.values()];
|
|
1084
|
+
const seedKey = sitemapUrlsEventSeedKey(ctx, feedpathHash);
|
|
1085
|
+
const seed = await readJson(seedKey);
|
|
1086
|
+
const isSeedGeneration = seed === void 0 || seed.generationId === generation.id;
|
|
1087
|
+
const seedRows = isSeedGeneration ? live.map((record) => ({
|
|
1088
|
+
feedpath: record.feedpath,
|
|
1089
|
+
feedpath_hash: feedpathHash,
|
|
1090
|
+
url_hash: record.urlHash,
|
|
1091
|
+
op: "added",
|
|
1092
|
+
loc: record.loc,
|
|
1093
|
+
lastmod: record.lastmod ?? null
|
|
1094
|
+
})) : [];
|
|
1095
|
+
const removalRows = live.map((record) => ({
|
|
1096
|
+
feedpath: record.feedpath,
|
|
1097
|
+
feedpath_hash: feedpathHash,
|
|
1098
|
+
url_hash: record.urlHash,
|
|
1099
|
+
op: "removed",
|
|
1100
|
+
loc: record.loc,
|
|
1101
|
+
lastmod: record.lastmod ?? null
|
|
1102
|
+
}));
|
|
1103
|
+
const proposedEvents = feedPosition === "newer" ? [...seedRows.map((row) => eventRow(generation, row, {
|
|
1104
|
+
sequence: 0,
|
|
1105
|
+
projectsState: false,
|
|
1106
|
+
seedGeneration: isSeedGeneration,
|
|
1107
|
+
generationKind: "reconcile"
|
|
1108
|
+
})), ...removalRows.map((row) => eventRow(generation, row, {
|
|
1109
|
+
sequence: seedRows.length > 0 ? 1 : 0,
|
|
1110
|
+
projectsState: true,
|
|
1111
|
+
seedGeneration: isSeedGeneration,
|
|
1112
|
+
generationKind: "reconcile"
|
|
1113
|
+
}))] : [];
|
|
1114
|
+
const persistedEvents = feedPosition === "newer" ? await ensureEvents(ctx, feedpathHash, generation, proposedEvents) : {
|
|
1115
|
+
rows: [],
|
|
1116
|
+
digest: checkpoint.eventDigest
|
|
1117
|
+
};
|
|
1118
|
+
if (isSeedGeneration && feedPosition === "newer") await writeJson(seedKey, {
|
|
1119
|
+
version: 1,
|
|
1120
|
+
generationId: generation.id,
|
|
1121
|
+
observedAt: generation.observedAt
|
|
768
1122
|
});
|
|
769
|
-
|
|
1123
|
+
const removedHashes = new Set(persistedEvents.rows.filter((row) => Boolean(row.projects_state) && String(row.op) === "removed").map((row) => String(row.url_hash)));
|
|
1124
|
+
const removedBeforeFeed = urlsRemoved;
|
|
1125
|
+
for (const record of live.filter((record) => removedHashes.has(record.urlHash))) {
|
|
1126
|
+
state.live.delete(record.urlHash);
|
|
1127
|
+
state.removed.set(record.urlHash, {
|
|
1128
|
+
...record,
|
|
1129
|
+
removedAt: generation.observedAt
|
|
1130
|
+
});
|
|
1131
|
+
urlsRemoved++;
|
|
1132
|
+
}
|
|
1133
|
+
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());
|
|
1134
|
+
if (feedPosition === "newer") {
|
|
1135
|
+
const feedCheckpoint = persistedEvents.rows.length > 0 ? checkpointFromEvents(persistedEvents.rows, persistedEvents.digest) : {
|
|
1136
|
+
_tag: "reconcile",
|
|
1137
|
+
version: 1,
|
|
1138
|
+
generationId: generation.id,
|
|
1139
|
+
observedAt: generation.observedAt,
|
|
1140
|
+
eventDigest: persistedEvents.digest
|
|
1141
|
+
};
|
|
1142
|
+
await writeJson(sitemapUrlsGenerationKey(ctx, feedpathHash), feedCheckpoint);
|
|
1143
|
+
if (persistedEvents.rows.length > 0) await ds.delete([sitemapUrlsPendingGenerationKey(ctx, feedpathHash)]);
|
|
1144
|
+
}
|
|
1145
|
+
if (urlsRemoved > removedBeforeFeed) feedpathsPruned++;
|
|
770
1146
|
}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1147
|
+
await writeJson(sitemapUrlsReconcileGenerationKey(ctx), {
|
|
1148
|
+
version: 1,
|
|
1149
|
+
generationId: generation.id,
|
|
1150
|
+
observedAt: generation.observedAt,
|
|
1151
|
+
inputDigest
|
|
776
1152
|
});
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
feedpathsPruned,
|
|
783
|
-
urlsRemoved
|
|
784
|
-
};
|
|
1153
|
+
return {
|
|
1154
|
+
feedpathsPruned,
|
|
1155
|
+
urlsRemoved
|
|
1156
|
+
};
|
|
1157
|
+
});
|
|
785
1158
|
}
|
|
786
1159
|
};
|
|
787
1160
|
}
|
|
@@ -866,4 +1239,4 @@ function createEmptyTypesStore(opts) {
|
|
|
866
1239
|
}
|
|
867
1240
|
};
|
|
868
1241
|
}
|
|
869
|
-
export { INSPECTION_HISTORY_MAX_BYTES, buildQueryDimRecords, createEmptyTypesStore, createIndexingMetadataStore, createInspectionStore, createQueryDimStore, createSitemapStore, emptyTypesKey, hashSortedUrlList, hashUrl, hashUrlList, indexingMetadataIndexKey, inspectionBaseKey, inspectionEventKey, inspectionEventsPrefix, inspectionHistoryPrefix, inspectionHistoryShardKey, inspectionIndexKey, inspectionParquetKey, queryDimMetaKey, queryDimParquetKey, sitemapHistoryKey, sitemapIndexKey, sitemapUrlsDeltaKey, sitemapUrlsIndexKey, sitemapUrlsIndexPrefix, sitemapUrlsPrefix };
|
|
1242
|
+
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, parseSitemapProjectionManifest, parseSitemapUrlsDeltaKey, queryDimMetaKey, queryDimParquetKey, selectSitemapProjectionFiles, sitemapHistoryKey, sitemapIndexKey, sitemapUrlsDeltaKey, sitemapUrlsEventKey, sitemapUrlsEventSeedKey, sitemapUrlsEventsPrefix, sitemapUrlsGenerationKey, sitemapUrlsIndexKey, sitemapUrlsIndexPrefix, sitemapUrlsPendingGenerationKey, sitemapUrlsPendingGenerationsPrefix, sitemapUrlsPrefix, sitemapUrlsProjectionManifestKey, sitemapUrlsReconcileGenerationKey, withSitemapProjectionFeed };
|