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
50 changes: 38 additions & 12 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,28 @@ app.onError((err, c) => {
});

/** Wrap a handler so OpError -> its HTTP status, anything else -> 500. */
function handle<T>(fn: () => Promise<T>) {
/**
* 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<T>(fn: () => Promise<T>, 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);
Expand Down Expand Up @@ -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' });
Expand All @@ -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
Expand Down Expand Up @@ -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) =>
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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);
});

Expand Down
7 changes: 6 additions & 1 deletion src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 9 additions & 1 deletion test/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
Expand Down
61 changes: 61 additions & 0 deletions test/public-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
22 changes: 15 additions & 7 deletions wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading