From 8dfd5b55fd62b8ef199b8549b972bc3e76a33922 Mon Sep 17 00:00:00 2001 From: barneyjm Date: Mon, 14 Sep 2026 09:11:24 -0400 Subject: [PATCH] Stop paying Neon to stay awake for work nobody asked for Neon bills compute by the hour it is awake, and an idle compute suspends after its autosuspend window. That makes every avoidable query worse than it looks: it is not the query that costs, it is the wake-up, which bills for the whole window whether or not there was anything to do. Four changes, all aimed at keeping the compute asleep: - The lease-expiry cron drops from hourly to daily. Hourly was 24 wake-ups a day -- most of a near-idle month's compute -- for work reclaimLapsedLocks() had almost always already done on the hot path. The lazy sweep is the real mechanism: a stranded reservation is reclaimed by the next pool read, and the only thing a pool read waits on is a volunteer who wants work. The cron is the floor under a system nobody is touching, where a blocked budget harms nobody. - /health no longer runs SELECT 1 by default. An uptime monitor on a 1-minute interval would hold the compute awake permanently and outspend all real traffic; the DB probe moves behind ?db=1 for when you actually want it. - Public reads (/leaderboard, /transparency, /tasks/available, and the per- conjecture tree + contributions) carry a shared-cache Cache-Control, so Cloudflare answers ordinary site traffic without touching Postgres. Successes only -- caching an error would pin a transient 500 at the edge for its full s-maxage. - The runner's poll interval defaults to 60s instead of 15s. Four idle polls a minute held the compute awake for a whole watch session, and a volunteer waiting up to a minute longer costs nothing when tasks run for minutes. test/public-cache.test.ts holds the rule both ways: public reads are cacheable, caller-specific reads and error responses never are. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WoAJRWLbAW5pakUUzEzcjV --- src/app.ts | 50 ++++++++++++++++++++++++-------- src/cli/commands.ts | 7 ++++- test/health.test.ts | 10 ++++++- test/public-cache.test.ts | 61 +++++++++++++++++++++++++++++++++++++++ wrangler.toml | 22 +++++++++----- 5 files changed, 129 insertions(+), 21 deletions(-) create mode 100644 test/public-cache.test.ts diff --git a/src/app.ts b/src/app.ts index eccb3d1..cea3d6f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -124,10 +124,28 @@ app.onError((err, c) => { }); /** Wrap a handler so OpError -> its HTTP status, anything else -> 500. */ -function handle(fn: () => Promise) { +/** + * Shared-cache policy for public, non-personalized reads. Neon bills compute by + * the hour it is awake, and an idle compute suspends — so every uncached page + * view is not just a query, it is a wake-up that bills for the whole autosuspend + * window. `s-maxage` lets Cloudflare's edge answer site traffic without touching + * Postgres at all; `stale-while-revalidate` keeps the edge serving while one + * request refreshes behind it. The short browser `max-age` means a contributor + * reloading their own numbers still sees them move. + * + * Only ever put this on responses that are identical for every caller. Anything + * behind requireDev/requireAdmin must stay uncached. + */ +const PUBLIC_CACHE = 'public, max-age=60, s-maxage=300, stale-while-revalidate=600'; + +function handle(fn: () => Promise, cacheControl?: string) { return async (c: any) => { try { - return c.json((await fn()) as any); + const body = (await fn()) as any; + // Successes only: an error response must never be cached at the edge, or + // a transient 500 would be served to everyone for the full s-maxage. + if (cacheControl) c.header('Cache-Control', cacheControl); + return c.json(body); } catch (err) { if (err instanceof OpError) { return c.json({ error: err.code, message: err.message }, err.status as any); @@ -219,7 +237,13 @@ app.get('/analytics-config.json', (c) => { // host root (api.givework.dev/health) and what uptime checks / load balancers // hit. Pings the database so a 200 means "control plane can actually serve", not // just "the Worker booted". DB unreachable -> 503 with status 'degraded'. +// Liveness by default, readiness on request. `SELECT 1` looks free, but it wakes +// Neon's compute and restarts its autosuspend countdown -- an uptime monitor on a +// 1-minute interval would hold compute awake permanently and cost more than all +// real traffic combined. So the default answers from the Worker alone, and the DB +// probe is opt-in via ?db=1 for the times you actually want to assert the DB. app.get('/health', async (c) => { + if (c.req.query('db') !== '1') return c.json({ status: 'ok', db: 'unchecked' }); try { await query('SELECT 1'); return c.json({ status: 'ok', db: 'up' }); @@ -232,7 +256,7 @@ app.get('/health', async (c) => { // and opt-in: only nonprofits an admin marked `listed` appear, and only their // name + counts (no contact info or task content). The marketing site can fetch // this to render a "who we work with" section. -app.get('/transparency', (c) => handle(() => getPublicTransparency())(c)); +app.get('/transparency', (c) => handle(() => getPublicTransparency(), PUBLIC_CACHE)(c)); // Media (conjecture explainer videos) streamed from R2 — stored there, never in // the repo. Range requests are honored so browsers can seek within a video. The @@ -336,7 +360,7 @@ app.get('/conjectures/:slug/tree', (c) => const tree = await getTargetTaskTree(c.req.param('slug')); if (!tree) throw new OpError(404, 'target_not_found', 'Unknown conjecture'); return tree; - })(c), + }, PUBLIC_CACHE)(c), ); app.get('/conjectures/:slug/contributions', (c) => @@ -347,7 +371,7 @@ app.get('/conjectures/:slug/contributions', (c) => }); if (!page) throw new OpError(404, 'target_not_found', 'Unknown conjecture'); return page; - })(c), + }, PUBLIC_CACHE)(c), ); // Minimal embeddable video player for twitter:player cards — the conjecture's @@ -378,7 +402,7 @@ app.get('/embed/:slug', async (c) => { // Public leaderboard — curated conjectures with progress + top contributors by // donated compute. Drives the marketing site's "what's being worked on" surface. -app.get('/leaderboard', (c) => handle(() => getLeaderboard())(c)); +app.get('/leaderboard', (c) => handle(() => getLeaderboard(), PUBLIC_CACHE)(c)); // Public work board — the open tasks anyone can browse before signing up. Scoped // in listAvailableTasks to public-sensitivity tasks on public slugged targets, so @@ -388,12 +412,14 @@ app.get('/tasks/available', (c) => { const slug = c.req.query('slug'); const deliverable = c.req.query('deliverable'); const limit = c.req.query('limit'); - return handle(() => - listAvailableTasks({ - slug: slug ?? undefined, - deliverable: deliverable ?? undefined, - limit: limit !== undefined ? Number(limit) : undefined, - }), + return handle( + () => + listAvailableTasks({ + slug: slug ?? undefined, + deliverable: deliverable ?? undefined, + limit: limit !== undefined ? Number(limit) : undefined, + }), + PUBLIC_CACHE, )(c); }); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 656f650..9fb57d0 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -391,7 +391,12 @@ export async function run( process.exit(1); } const intervalArg = arg(args, '--interval'); - const intervalSec = intervalArg ? Number(intervalArg) : 15; + // 60s, not 15s: an idle poll is a Neon compute wake-up, and four of them a + // minute hold the serverless compute awake for the whole watch session. A + // volunteer waiting up to a minute longer to pick up a task costs nothing -- + // the tasks themselves run for minutes. Override with --interval when you are + // deliberately racing a specific task into the pool. + const intervalSec = intervalArg ? Number(intervalArg) : 60; if (!Number.isFinite(intervalSec) || intervalSec <= 0) { console.error('--interval must be a positive number of seconds'); process.exit(1); diff --git a/test/health.test.ts b/test/health.test.ts index f1f3f5c..3ed4677 100644 --- a/test/health.test.ts +++ b/test/health.test.ts @@ -5,9 +5,17 @@ import { app } from '../src/server.js'; afterAll(closePool); describe('GET /health', () => { - it('returns 200 + ok when the database is reachable (no auth required)', async () => { + // Liveness by default: no DB round-trip, so an uptime monitor cannot hold + // Neon's compute awake (and bill for it) just by checking we are up. + it('answers without touching the database (no auth required)', async () => { const res = await app.fetch(new Request('http://test/health')); expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: 'ok', db: 'unchecked' }); + }); + + it('probes the database only when asked for it with ?db=1', async () => { + const res = await app.fetch(new Request('http://test/health?db=1')); + expect(res.status).toBe(200); expect(await res.json()).toEqual({ status: 'ok', db: 'up' }); }); }); diff --git a/test/public-cache.test.ts b/test/public-cache.test.ts new file mode 100644 index 0000000..1936f06 --- /dev/null +++ b/test/public-cache.test.ts @@ -0,0 +1,61 @@ +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { closePool } from '../src/db.js'; +import { app } from '../src/server.js'; +import { createDev, mintDevToken, resetDb, setBudget } from './helpers.js'; + +// Neon bills compute by the hour it is awake, and an idle compute suspends. So +// every uncached public read is not just a query -- it is a wake-up that bills +// for the whole autosuspend window. These endpoints are identical for every +// caller, so Cloudflare's edge can answer site traffic without touching Postgres +// at all. The rule these tests hold: public reads carry a shared-cache header, +// and anything caller-specific never does. + +afterAll(closePool); + +let devTok: string; +beforeEach(async () => { + await resetDb(); + const devId = await createDev('cache-dev'); + await setBudget(devId, 500); + devTok = await mintDevToken(devId); +}); + +const req = (path: string, init?: RequestInit) => + app.fetch(new Request(`http://test${path}`, init)); + +const PUBLIC_READS = ['/leaderboard', '/transparency', '/tasks/available']; + +describe('public reads are edge-cacheable', () => { + for (const path of PUBLIC_READS) { + it(`${path} sets a shared-cache Cache-Control`, async () => { + const res = await req(path); + expect(res.status).toBe(200); + const cc = res.headers.get('cache-control') ?? ''; + expect(cc).toMatch(/s-maxage=\d+/); + expect(cc).toContain('public'); + }); + } +}); + +describe('caller-specific responses are never cached', () => { + it('a dev-gated read carries no shared-cache header', async () => { + const res = await req('/budget', { headers: { authorization: `Bearer ${devTok}` } }); + expect(res.status).toBe(200); + // Caching this at the edge would serve one volunteer's budget to another. + expect(res.headers.get('cache-control') ?? '').not.toContain('s-maxage'); + }); + + it('an error response is not cached, so a blip cannot be pinned at the edge', async () => { + const res = await req('/conjectures/no-such-conjecture-xyz/tree'); + expect(res.status).toBe(404); + expect(res.headers.get('cache-control') ?? '').not.toContain('s-maxage'); + }); +}); + +describe('/health stays off the database unless asked', () => { + it('the default probe does not set a cache header either', async () => { + const res = await req('/health'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: 'ok', db: 'unchecked' }); + }); +}); diff --git a/wrangler.toml b/wrangler.toml index 77de7c1..0979811 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -100,14 +100,22 @@ enabled = true # a stranded reservation within one request. The cron only matters when the # system is otherwise idle — and a query every 5 minutes is exactly what defeats # Neon's autosuspend (default 300s idle), pinning the serverless compute on 24/7 -# and burning through the monthly CU-hour allowance for no work. An hourly tick -# lets the compute scale to zero between bursts while still capping how long a -# crashed runner's reservation can linger in a fully idle system at ~1h (the -# lease itself is 10 min; the delay is only the budget-refund backstop). If you -# ever need faster idle reclaim, lower this AND raise the Neon autosuspend -# window so the two don't fight. +# and burning through the monthly CU-hour allowance for no work. +# +# Hourly was the first cut of that reasoning, and it was still 24 wake-ups a day: +# each tick bills a full autosuspend window (~5 min) whether or not there was +# anything to reclaim, which is most of a near-idle month's compute spend for +# work the lazy path had almost always already done. +# +# So: DAILY. The lazy path is the real mechanism — a stranded reservation is +# reclaimed by the next pool read, and the only thing a pool read can be waiting +# on is a volunteer who wants work, which is exactly when it matters. This tick +# is the floor under the case where nobody touches the system at all — where a +# blocked budget also harms nobody until someone shows up. If you ever need +# faster idle reclaim, raise the cadence AND shorten the Neon autosuspend window +# so the two don't fight. [triggers] -crons = ["0 * * * *"] +crons = ["17 4 * * *"] # Outbound transactional email via Cloudflare Email Sending (the domain # givework.dev is onboarded there, so SPF/DKIM are aligned). The Worker sends the