From ea13bbb6ea671babd3ec8274371f61eb412f2e52 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 21 Jun 2026 02:49:18 -0500 Subject: [PATCH] Record a reindex heartbeat on every run for cron observability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skipped/unchanged reindex runs previously left no trace, so a scheduled (cron) run that found docs unchanged was unobservable — Vercel exposes no historical runtime-log or cron-run-history API on this plan. Persist a lightweight heartbeat (checkedAt, trigger, status, reason, docsHash) to Redis key `salem:docs:index:lastcheck` on every evaluation, including skips. recordCheck is optional on ReindexStateStore so existing and test stores stay compatible; the write is best-effort and never fails the reindex. Now every scheduled run is verifiable with one Redis read. Co-Authored-By: Claude Opus 4.8 (1M context) --- rag/reindex-freshness.ts | 123 ++++++++++++++++++++++++++------------- 1 file changed, 82 insertions(+), 41 deletions(-) diff --git a/rag/reindex-freshness.ts b/rag/reindex-freshness.ts index 8a71573..1f80b6a 100644 --- a/rag/reindex-freshness.ts +++ b/rag/reindex-freshness.ts @@ -7,6 +7,7 @@ import { } from "./indexer"; const REINDEX_STATE_KEY = "salem:docs:index:last"; +const REINDEX_HEARTBEAT_KEY = "salem:docs:index:lastcheck"; export type { IndexResult }; @@ -22,6 +23,9 @@ export interface ReindexState { export interface ReindexStateStore { get(): Promise; set(state: ReindexState): Promise; + // Optional: record a per-run heartbeat. Stores without it simply skip + // heartbeat tracking (keeps existing/custom stores backward-compatible). + recordCheck?(heartbeat: ReindexHeartbeat): Promise; } export interface ReindexDecision { @@ -35,6 +39,19 @@ export interface ReindexDecision { errors: string[]; } +/** + * Recorded on every reindex evaluation — including skipped/unchanged runs that + * otherwise leave no trace — so scheduled (cron) runs are observable: read + * `salem:docs:index:lastcheck` from Redis to see when the cron last fired. + */ +export interface ReindexHeartbeat { + checkedAt: string; + trigger: string; + status: ReindexDecision["status"]; + reason: ReindexDecision["reason"]; + docsHash: string; +} + interface ReindexDocsOptions { trigger: string; force?: boolean; @@ -57,6 +74,10 @@ class RedisReindexStateStore implements ReindexStateStore { async set(state: ReindexState): Promise { await this.redis.set(REINDEX_STATE_KEY, state); } + + async recordCheck(heartbeat: ReindexHeartbeat): Promise { + await this.redis.set(REINDEX_HEARTBEAT_KEY, heartbeat); + } } export function createRedisReindexStateStore(): ReindexStateStore | null { @@ -96,8 +117,10 @@ export async function reindexDocsIfChanged({ const previousHash = previous?.docsHash ?? null; const stateStorage = store ? (store instanceof RedisReindexStateStore ? "redis" : "custom") : "disabled"; + let decision: ReindexDecision; + if (!force && previousHash === docsHash) { - return { + decision = { status: "skipped", reason: "unchanged", docsHash, @@ -116,48 +139,66 @@ export async function reindexDocsIfChanged({ stateStorage, errors: [], }; + } else { + const result = await indexDocs(); + const reason = force ? "forced" : previousHash ? "changed" : "first-index"; + + if (!result.success) { + decision = { + status: "error", + reason: "index-failed", + docsHash, + docsLength: docsText.length, + previousHash, + result, + stateStorage, + errors: result.errors, + }; + } else { + if (store) { + await store.set({ + docsHash, + docsLength: docsText.length, + docsUrl: LLMS_FULL_URL, + indexedAt: new Date().toISOString(), + trigger, + result: { + pagesProcessed: result.pagesProcessed, + chunksCreated: result.chunksCreated, + uniqueTerms: result.uniqueTerms, + duration: result.duration, + }, + }); + } + + decision = { + status: "indexed", + reason, + docsHash, + docsLength: docsText.length, + previousHash, + result, + stateStorage, + errors: [], + }; + } } - const result = await indexDocs(); - const reason = force ? "forced" : previousHash ? "changed" : "first-index"; - - if (!result.success) { - return { - status: "error", - reason: "index-failed", - docsHash, - docsLength: docsText.length, - previousHash, - result, - stateStorage, - errors: result.errors, - }; - } - - if (store) { - await store.set({ - docsHash, - docsLength: docsText.length, - docsUrl: LLMS_FULL_URL, - indexedAt: new Date().toISOString(), - trigger, - result: { - pagesProcessed: result.pagesProcessed, - chunksCreated: result.chunksCreated, - uniqueTerms: result.uniqueTerms, - duration: result.duration, - }, - }); + // Heartbeat: record every evaluation (incl. skips) so scheduled runs are + // observable. Best-effort — a heartbeat failure must not fail the reindex. + if (store?.recordCheck) { + try { + await store.recordCheck({ + checkedAt: new Date().toISOString(), + trigger, + status: decision.status, + reason: decision.reason, + docsHash, + }); + } catch (error) { + console.warn("Reindex heartbeat write failed:", error); + } } - return { - status: "indexed", - reason, - docsHash, - docsLength: docsText.length, - previousHash, - result, - stateStorage, - errors: [], - }; + return decision; }