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