From d506447010b3548ccfac7123c353c0bd042952c4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 13 Aug 2026 11:31:58 +0000 Subject: [PATCH] blog: serve last known good instead of 500 when SQLite Cloud blinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c0mpute.com/blog and /blog/rss.xml intermittently answer 500 — observed returning 500 and 200 minutes apart with no deploy in between, sometimes the page failing while the feed succeeded and sometimes the reverse. listPosts() already retries once through withReconnect(), but when the retry fails too the error reaches the route and Next answers a bare 500. That matters most for the feed. A blog page that fails is a page someone reloads; a feed that 500s is one every reader eventually stops polling and treats as dead, and this feed is listed in profullstack.com/feeds.opml and in the smallweb list. So reads go through readCached(), which keeps a last-known-good copy and serves it when the database cannot be reached. An outage now degrades to slightly stale posts rather than to no posts. When nothing has ever succeeded the error is still thrown, because an empty feed served as 200 would claim the blog has no posts at all. The copy is kept in process, not only in Redis. REDIS_URL is referenced exactly once in this repository — by getRedis() — and set nowhere: no .env.example, no deploy config, no documentation. Every Redis path here is already a no-op in production, so a fallback that lived only in Redis would never once be read. `next start` is long-running, so a Map covers precisely the failure this is for. The Redis copy is written too, under `blog:stale:` rather than `blog:list:`, because cacheDelListKeys() wipes the latter on every publish and would throw the safety net away at the moment the site is being written to. Used only on the failure path, never as the happy-path cache: publishing invalidates through Redis, so an in-process fresh cache would hide new posts for a full TTL. getPost() is left alone — it deliberately does not cache misses, and a single post page failing does not make a feed look dead. Verified: tsc --noEmit clean, next build succeeds, blog routes still dynamic. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/lib/blog-db.ts | 122 +++++++++++++++++++++++++++++++----- 1 file changed, 105 insertions(+), 17 deletions(-) diff --git a/apps/web/src/lib/blog-db.ts b/apps/web/src/lib/blog-db.ts index a5a3f85..3cb48f5 100644 --- a/apps/web/src/lib/blog-db.ts +++ b/apps/web/src/lib/blog-db.ts @@ -150,6 +150,99 @@ async function cacheDelListKeys(): Promise { const CACHE_TTL = 30 * 60; +// How long a last-known-good copy is kept to serve when SQLite Cloud is +// unreachable. Long, because its only job is to cover an outage: a week-old +// post list is a far better answer than a 500. +const STALE_TTL = 7 * 24 * 60 * 60; + +// Deliberately NOT under `blog:list:` — cacheDelListKeys() wipes that prefix on +// every publish, which would throw the safety net away at the exact moment the +// site is being written to. +function staleKey(key: string): string { + return `blog:stale:${key}`; +} + +async function staleSet(key: string, value: unknown): Promise { + try { + const redis = getRedis(); + if (!redis) return; + await redis.set(staleKey(key), JSON.stringify(value), "EX", STALE_TTL); + } catch {} +} + +async function staleGet(key: string): Promise { + try { + const redis = getRedis(); + if (!redis) return null; + const raw = await redis.get(staleKey(key)); + return raw ? (JSON.parse(raw) as T) : null; + } catch { + return null; + } +} + +// Last-known-good, in this process. +// +// REDIS_URL is referenced exactly once in this repository — by getRedis(), a +// few lines above — and is set nowhere: no .env.example, no deploy config, no +// documentation. So every Redis path here is a no-op in production, and a +// last-known-good copy that lives only in Redis would never once be read. +// +// `next start` is a long-running server, so a plain Map outlives any number of +// requests and covers exactly the failure this is for: a database that works, +// then briefly does not, then works again. +// +// Used ONLY on the failure path, never as the happy-path cache. Publishing +// invalidates through cacheDelListKeys(), which can only reach Redis; an +// in-process *fresh* cache would therefore hide new posts for a full TTL. +const lastGood = new Map(); + +// Enough for every (limit, offset) a page or feed asks for, bounded so a +// hostile or buggy caller cannot grow it without end. +const LAST_GOOD_MAX = 64; + +function rememberGood(key: string, value: unknown): void { + if (!lastGood.has(key) && lastGood.size >= LAST_GOOD_MAX) { + const oldest = lastGood.keys().next().value; + if (oldest !== undefined) lastGood.delete(oldest); + } + lastGood.set(key, value); +} + +/** + * Read through the cache, falling back to the last-known-good copy. + * + * The blog is served from SQLite Cloud, which intermittently refuses a + * connection — c0mpute.com/blog and its feed were observed returning 500 and + * 200 minutes apart with no deploy in between. withReconnect() already retries + * once, but when the retry fails too the error reaches the route and Next + * answers 500, and a feed that 500s is one every reader eventually treats as + * dead. So an outage degrades to slightly stale content rather than to none. + * + * If nothing has ever succeeded in this process, the error is rethrown: an + * empty feed served as 200 would claim the blog has no posts, which is worse + * than an honest failure. + */ +async function readCached(cacheKey: string, load: () => Promise): Promise { + const cached = await cacheGet(cacheKey); + if (cached) return cached; + + try { + const fresh = await load(); + await cacheSet(cacheKey, fresh); + await staleSet(cacheKey, fresh); + rememberGood(cacheKey, fresh); + return fresh; + } catch (e) { + const stale = (lastGood.get(cacheKey) as T | undefined) ?? (await staleGet(cacheKey)); + if (stale) { + console.error(`blog-db: ${cacheKey} failed, serving last known good —`, e); + return stale; + } + throw e; + } +} + // ── Public types & helpers ──────────────────────────────────────────────────── export interface BlogPost { @@ -200,23 +293,18 @@ export async function upsertPost(post: NewPost): Promise { } export async function listPosts(limit = 50, offset = 0): Promise { - const cacheKey = `blog:list:${limit}:${offset}`; - const cached = await cacheGet(cacheKey); - if (cached) return cached; - - const posts = await withReconnect(async () => { - const db = await getDb(); - const rows = await db - .select() - .from(blogPosts) - .orderBy(desc(blogPosts.published_at)) - .limit(limit) - .offset(offset); - return rows.map(hydrate); - }); - - await cacheSet(cacheKey, posts); - return posts; + return readCached(`blog:list:${limit}:${offset}`, () => + withReconnect(async () => { + const db = await getDb(); + const rows = await db + .select() + .from(blogPosts) + .orderBy(desc(blogPosts.published_at)) + .limit(limit) + .offset(offset); + return rows.map(hydrate); + }), + ); } export async function getPost(slug: string): Promise {