Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ A Git push does not deploy this pipeline. Changes to `scripts/sync/` or `scripts
| [`scripts/sync/lib/episode-book.js`](sync/lib/episode-book.js) + [`episode-book-cli.js`](sync/episode-book-cli.js) | The episode book in sqld: one permanent row per (show, catalog key) with its recipe ID and served ID, plus Podcast Index aliases, `served_show` and `show_window`. Every book write also marks the show in `served_show` when one of its episodes is listed by Podcast Index under an ID other than its served ID (renumbered, duplicated or recipe-served), so the Worker's v1 overlay translates that ID back. That check reaches a show's aliases through `episode_pi_alias_rid` (an index on `rid`); without it each check scanned every alias inside the write. `ensure` creates the tables, indexes and columns; `stats` counts. Writes go through `turso.atomic()` (one Hrana batch: every step runs only after the previous one succeeded, then COMMIT, else ROLLBACK). Rules: [`docs/EPISODE_IDS.md`](../docs/EPISODE_IDS.md) |
| [`scripts/sync/lib/episode-identity.js`](sync/lib/episode-identity.js) | Stage 4's episode identity: each window episode gets its catalog key and recipe ID, and its point is named by the book's served ID. A new episode's book row (served ID = its current Podcast Index ID) is written before its vector; a Podcast Index ID change keeps the point and adds an alias instead of re-embedding. Points are stamped `eid` and `catalog_key` (`eid_missing` without a key). A new key whose Podcast Index ID is already served for another key is deferred: today's naming, no row. Its point belongs to the owner: stamped with the owner's `eid` and `catalog_key` when the owner is in the same show (a changed GUID), or `eid_shared` with the owning show when it is another show (a duplicate feed: Podcast Index lists one podcast under two show IDs that share episode IDs). If the book write fails, the show keeps today's naming for that run and the bootstrap's `--catch-up` records it later; a finished catch-up pass starts over when run again |
| [`scripts/sync/bootstrap-book.js`](sync/bootstrap-book.js) + [`lib/book-bootstrap.js`](sync/lib/book-bootstrap.js) | One resumable pass over every show with episode points: maps each point's payload ID (its Podcast Index ID) to a catalog key through PI's episode list, writes the show's book rows in one transaction (served ID = that PI ID), then stamps `eid` and `catalog_key` on the points (`eid_missing` when unmapped). A point whose Podcast Index ID another key already serves gets no row, so the show's transaction can't fail on it: it is stamped with that key's identity when it is in the same show, or `eid_shared` with the owning show for a duplicate feed. Point IDs and vectors never change. `--dry`, `--limit-shows N`, `--minutes N`; progress in `SYNC_BOOTSTRAP_STATE`. `--catch-up` takes only shows that still have points with none of `eid`, `eid_missing` and `eid_shared`. `--retry-missing` (own-episode-IDs P3; monthly on the VPS) runs the pass again for shows with `eid_missing` points: a point mapped this time gets its stamp and loses the mark |
| [`scripts/sync/lib/charted-at.js`](sync/lib/charted-at.js) + [`stamp-charted-at.js`](sync/stamp-charted-at.js) | Last-charted dates on Qdrant points (own-episode-IDs P3b): every point in both collections carries `charted_at`, the UTC day (epoch seconds) its show was last seen on a chart, so a 90-day vector retention can outlive the show's catalog row and state record. Stage 6 stamps every show it keeps from its chart clock (`shows[id].s`) before deleting; a point is rewritten only when it has no date or one more than `CHARTED_AT_REFRESH_DAYS` (default 7) older than its show's, so dates only move forward and a charting show's points are rewritten about weekly. Stages 4 and 5 give new points the day they are embedded. Failures are logged and never block the cleanup. `stamp-charted-at.js` stamps every point once from the catalog and the chart clock without changing anything else (`--dry` counts only; run from the repository root under the shared job lock). Nothing deletes by this date yet |
| [`scripts/sync/lib/episode-recipe-id.js`](sync/lib/episode-recipe-id.js) | boxlore episode IDs from (show ID, catalog key); must match the app's `EpisodeRecipeId` and the shared fixture in `core/rss/src/test/resources/fixtures/` |
| [`scripts/package.json`](package.json) | Sync Node deps + `npm run test:sync` |

Expand Down
2 changes: 1 addition & 1 deletion scripts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"test:sync-scalars": "node --test sync/lib/scalars.test.js",
"test:sync-turso-page": "node --test sync/lib/turso-page.test.js",
"test:sync-turso": "node --test sync/lib/turso.test.js",
"test:sync": "node --test sync/lib/csv.test.js sync/lib/pi-unavailable.test.js sync/lib/scalars.test.js sync/lib/turso-page.test.js sync/lib/episode-caps.test.js sync/lib/staleness.test.js sync/lib/podcast-index.test.js sync/lib/chart-countries.test.js sync/lib/log.test.js sync/lib/text.test.js sync/lib/vectorize-lanes.test.js sync/lib/tip-queue.test.js sync/lib/import-missing.test.js sync/lib/episode-recipe-id.test.js sync/lib/attribution.test.js sync/lib/vector-window.test.js sync/lib/trim-episode-caps.test.js sync/lib/remediate-qdrant.test.js sync/lib/turso.test.js sync/lib/catalog-rows.test.js sync/lib/episode-book.test.js sync/lib/book-bootstrap.test.js sync/lib/episode-identity.test.js sync/lib/stale-tips.test.js sync/lib/show-window.test.js sync/lib/cadence.test.js",
"test:sync": "node --test sync/lib/csv.test.js sync/lib/pi-unavailable.test.js sync/lib/scalars.test.js sync/lib/turso-page.test.js sync/lib/episode-caps.test.js sync/lib/staleness.test.js sync/lib/podcast-index.test.js sync/lib/chart-countries.test.js sync/lib/log.test.js sync/lib/text.test.js sync/lib/vectorize-lanes.test.js sync/lib/tip-queue.test.js sync/lib/import-missing.test.js sync/lib/episode-recipe-id.test.js sync/lib/attribution.test.js sync/lib/vector-window.test.js sync/lib/trim-episode-caps.test.js sync/lib/remediate-qdrant.test.js sync/lib/turso.test.js sync/lib/catalog-rows.test.js sync/lib/episode-book.test.js sync/lib/book-bootstrap.test.js sync/lib/episode-identity.test.js sync/lib/stale-tips.test.js sync/lib/show-window.test.js sync/lib/cadence.test.js sync/lib/charted-at.test.js",
"test:sync-text": "node --test sync/lib/text.test.js",
"test:sync-recipe": "node --test sync/lib/episode-recipe-id.test.js",
"test:sync-attribution": "node --test sync/lib/attribution.test.js",
Expand Down
3 changes: 3 additions & 0 deletions scripts/sync/04-vectorize-episodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const {
const scalars = require('./lib/scalars');
const showWindow = require('./lib/show-window');
const attribution = require('./lib/attribution');
const chartedAt = require('./lib/charted-at');

const UPSERT_BATCH = 100;

Expand Down Expand Up @@ -508,6 +509,8 @@ async function main() {
continue;
}
if (item.stamps) Object.assign(prepared.payload, item.stamps);
// Embedded shows chart today (lib/charted-at.js); the cleanup moves the date on.
prepared.payload[chartedAt.FIELD] = chartedAt.dayStartSec(Date.now());
if (prepared.scrubbed.length) {
log.warn(
`Scrubbed optional ep fields for show ${pod.id}: ${prepared.scrubbed.join(',')}`,
Expand Down
3 changes: 3 additions & 0 deletions scripts/sync/05-vectorize-shows.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const text = require('./lib/text');
const cfg = require('./lib/config');
const scalars = require('./lib/scalars');
const attribution = require('./lib/attribution');
const chartedAt = require('./lib/charted-at');
const {
loadCountriesByItunesId,
itunesInCountries,
Expand Down Expand Up @@ -212,6 +213,8 @@ async function main() {
`Scrubbed optional show fields for ${pod.id}: ${prepared.scrubbed.join(',')}`,
);
}
// Embedded shows chart today (lib/charted-at.js); the cleanup moves the date on.
prepared.payload[chartedAt.FIELD] = chartedAt.dayStartSec(Date.now());
pointsQueue.push({
id: uuid,
vector,
Expand Down
32 changes: 29 additions & 3 deletions scripts/sync/06-cleanup-non-chart.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
* - Episode-cap trim is best-effort: failure is logged but does not roll
* back a successful non-chart cleanup.
*
* Last-charted dates (lib/charted-at.js, P3b): before deleting, every kept show's Qdrant points
* get the day it was last seen on a chart. Best-effort: a failure never blocks the cleanup.
*
* FTS safety: the podcasts_ad delete trigger does a full FTS-content scan
* PER ROW, so we drop it, bulk-delete, clean FTS orphans in one scan, and
* recreate it.
Expand All @@ -33,6 +36,7 @@ const handoff = require('./lib/pi-handoff');
const { trimEpisodeCaps } = require('./lib/trim-episode-caps');
const { CHARTS_ITUNES_FIRST_CURSOR } = require('./lib/chart-countries');
const attribution = require('./lib/attribution');
const chartedAt = require('./lib/charted-at');

const QDRANT_CHUNK = 500;
const DELETE_CHUNK = 200;
Expand All @@ -50,6 +54,24 @@ async function runCapTrim() {
}
}

/** Stamps kept shows' points with their last chart day. Never throws; returns the report line. */
async function stampChartedAt(pods, shows) {
try {
for (const c of chartedAt.COLLECTIONS) await qdrant.ensurePayloadIndex(c.name, chartedAt.FIELD, 'integer');
const r = await chartedAt.stamp(qdrant, chartedAt.planBatches(pods, shows), {
collections: chartedAt.COLLECTIONS,
refreshSec: cfg.CHARTED_AT_REFRESH_DAYS * chartedAt.DAY_S,
warn: (m) => log.warn(m),
});
const line = chartedAt.summary(r);
log.info(`[CHARTED_AT] ${line}`);
return line;
} catch (e) {
log.warn(`[CHARTED_AT] skipped: ${e.message}`);
return 'charted_at skipped';
}
}

async function main() {
turso.assertEnv();
qdrant.assertEnv();
Expand Down Expand Up @@ -149,14 +171,18 @@ async function main() {
// Persist grace clocks even when nothing is deleted yet.
state.save(st);

// Last-charted dates for the shows that stay (points of shows deleted below go anyway).
const deleting = new Set(toDelete.map(p => p.id));
const chartedLine = await stampChartedAt(allPods.filter(p => !deleting.has(p.id)), st.shows);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (toDelete.length === 0) {
log.info('Nothing past the grace period - done');
const trim = await runCapTrim();
log.summaryTable('Stage 6: Cleanup', [{
stage: 'cleanup-non-chart',
reads: turso.getStats().reads,
writes: turso.getStats().writes,
detail: `0 deleted, ${inGrace} in grace, cap-trim ${trim.deletedPoints} pts${trim.failed ? ' (trim failed)' : ''}`,
detail: `0 deleted, ${inGrace} in grace, cap-trim ${trim.deletedPoints} pts${trim.failed ? ' (trim failed)' : ''} · ${chartedLine}`,
}]);
if (trim.failed) process.exitCode = 1;
return;
Expand Down Expand Up @@ -206,7 +232,7 @@ async function main() {
stage: 'cleanup-non-chart',
reads: turso.getStats().reads,
writes: turso.getStats().writes,
detail: `0 turso deleted (${toDelete.length} pending qdrant), cap-trim ${trim.deletedPoints} pts`,
detail: `0 turso deleted (${toDelete.length} pending qdrant), cap-trim ${trim.deletedPoints} pts · ${chartedLine}`,
}]);
process.exitCode = 1;
return;
Expand Down Expand Up @@ -276,7 +302,7 @@ async function main() {
stage: 'cleanup-non-chart',
reads: stats.reads,
writes: stats.writes,
detail: `${qdrantOk.length}/${toDelete.length} shows deleted, ${inGrace} in grace, ${pruned} state pruned, cap-trim ${trim.deletedPoints} pts${trim.failed || qdrantFailedChunks ? ' (partial/errors)' : ''}`,
detail: `${qdrantOk.length}/${toDelete.length} shows deleted, ${inGrace} in grace, ${pruned} state pruned, cap-trim ${trim.deletedPoints} pts${trim.failed || qdrantFailedChunks ? ' (partial/errors)' : ''} · ${chartedLine}`,
}]);

if (trim.failed || qdrantFailedChunks > 0) process.exitCode = 1;
Expand Down
109 changes: 109 additions & 0 deletions scripts/sync/lib/charted-at.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
'use strict';

/**
* Last-charted dates on Qdrant points (own-episode-IDs P3b, plan section 10b item 4).
*
* Every point in both collections carries `charted_at`: the UTC day (epoch seconds) its show was
* last seen on a chart. The show's catalog row and state record go 7 days after it leaves the
* charts, so the date has to live on the points for the 90-day vector retention to read it.
* Nothing here deletes: the cleanup keeps its 7-day rule until the 90-day rule is switched on.
*
* Stamps come from the cleanup's chart clock (state `shows[id].s`, set daily by stage 6):
* - a show's points get the day of its last chart sighting;
* - a point is rewritten only when it has no date or its date is more than REFRESH_DAYS older
* than the show's, so dates only move forward and a charting show's points are rewritten
* about once a week, not daily;
* - new points get the day they were embedded (stage 4 and 5), since those shows chart.
*/

const cfg = require('./config');

const FIELD = 'charted_at';
/** Both collections: episodes name their show in podcast_id, show points in id. */
const COLLECTIONS = [
{ name: cfg.EPISODES_COLLECTION, showKey: 'podcast_id' },
{ name: cfg.PODCASTS_COLLECTION, showKey: 'id' },
];
const DAY_S = 86_400;
/** Shows per Qdrant filter call. */
const CHUNK = 500;

/** UTC day start in epoch seconds for a time in ms. */
function dayStartSec(ms) {
return Math.floor(Number(ms) / 1000 / DAY_S) * DAY_S;
}

/**
* Groups shows by stamp day into filter-sized batches.
* @param {{ id: string|number }[]} pods
* @param {object} shows state.shows (rec.s = last chart sighting, ms)
* @returns {{ value: number, ids: number[] }[]} newest day first; shows without a sighting are skipped
*/
function planBatches(pods, shows, chunk = CHUNK) {
const byDay = new Map();
for (const pod of pods || []) {
const id = Number(pod.id);
const seen = shows?.[String(pod.id)]?.s;
if (!Number.isSafeInteger(id) || id <= 0 || !(seen > 0)) continue;
const day = dayStartSec(seen);
if (!byDay.has(day)) byDay.set(day, []);
byDay.get(day).push(id);
}
const out = [];
for (const day of [...byDay.keys()].sort((a, b) => b - a)) {
const ids = byDay.get(day).sort((a, b) => a - b);
for (let i = 0; i < ids.length; i += chunk) out.push({ value: day, ids: ids.slice(i, i + chunk) });
}
return out;
}

/**
* Points of these shows whose date is missing or more than refreshSec older than `value`.
* @param {'podcast_id'|'id'} showKey episodes use podcast_id, the podcasts collection id
*/
function staleFilter(showKey, ids, value, refreshSec) {
return {
must: [{ key: showKey, match: { any: ids } }],
should: [
{ is_empty: { key: FIELD } },
{ key: FIELD, range: { lt: value - refreshSec } },
],
};
}

/**
* Stamps both collections. Never throws: a failed batch is counted and the rest still run.
* @param {{ setPayloadByFilter: Function, countPoints: Function }} qdrant
* @param {{ value: number, ids: number[] }[]} batches
* @param {{ collections: { name: string, showKey: string }[], refreshSec: number, dry?: boolean,
* warn?: (msg: string) => void }} opts
* @returns {Promise<{ batches: number, failed: number, points: Record<string, number>, dry: boolean }>}
* points: per collection, points that needed a date (counted before writing)
*/
async function stamp(qdrant, batches, { collections, refreshSec, dry = false, warn = () => {} }) {
const out = { batches: batches.length, failed: 0, points: {}, dry };
for (const c of collections) out.points[c.name] = 0;
for (const b of batches) {
for (const c of collections) {
const filter = staleFilter(c.showKey, b.ids, b.value, refreshSec);
try {
const n = await qdrant.countPoints(c.name, filter);
out.points[c.name] += n;
if (!dry && n > 0) await qdrant.setPayloadByFilter(c.name, { [FIELD]: b.value }, filter, true);
} catch (e) {
out.failed++;
warn(`[CHARTED_AT] ${c.name}: ${b.ids.length} shows at ${new Date(b.value * 1000).toISOString().slice(0, 10)} not stamped (${e.message})`);
}
}
}
return out;
}

/** One line for the run report. */
function summary(r) {
const pts = Object.entries(r.points).map(([name, n]) => `${name} ${n}`).join(', ');
return `charted_at ${r.dry ? 'dry run' : 'stamped'}: ${pts} points in ${r.batches} batches` +
(r.failed ? `, ${r.failed} failed calls` : '');
}

module.exports = { FIELD, COLLECTIONS, CHUNK, DAY_S, dayStartSec, planBatches, staleFilter, stamp, summary };
Loading
Loading