feat(web): move cache refresh crons from GitHub Actions to Vercel Cron - #468
Conversation
Moves each 'if (require.main === module)' bootstrap out of the refresh modules and into a '*.cli.ts' sibling, so the modules stay importable without a module-scope process.exit(). Exports the refresh functions and repoints the ingest container specs at refresh-vaults.cli.ts.
Replaces the five scheduled GitHub Actions workflows with authenticated App Router routes under app/api/cron/, scheduled by vercel.json. The shared handler gates on CRON_SECRET with timingSafeEqual and pushes up/down heartbeats to Uptime Kuma. The historical timeseries rebuild does not fit in one invocation: GitHub allowed 360 minutes, Vercel caps at 800s. It now runs hourly over 1/24 of the vaults per tick, hashed by chainId:address so a vault keeps its shard as the list grows, giving each vault a full rebuild once a day. The reports jobs were workflow_dispatch-only, so their routes carry no cron entry and are triggered by an authenticated request. Also adds an error listener to the rest cache redis client, which now holds an open socket between invocations instead of dying with the CI process.
Adds a test job running 'bun --filter web test'. Documents the cron routes, their env contract, and manual invocation in the web README and .env.local.example, and repoints the trace-propagation skill at the cron route now that refresh-cache.yml is gone.
Full historical rebuild fits Vercel's 800s (223-422s on Actions), so timeseries-refresh-historical runs a daily full pass instead of hourly 1/24 shards. CLI bootstraps share runCli(). Refresh modules log elapsed ms instead of console.time. SHARD_TOTAL route export removed (broke next build). Stale runbook paths, env example, and README env scoping fixed.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
matheus1lva
left a comment
There was a problem hiding this comment.
Bearer auth is timing-safe with length check, Kuma push is best-effort with a timeout, and the 500-on-failure path is covered by handler.spec.ts. No defects found.
How This Was Reviewed
Reviewed with the review-pr-workflow skill —
5 review lenses, each finding independently verified by claude. 1 candidate finding was refuted and dropped.
murderteeth
left a comment
There was a problem hiding this comment.
Summary
Moves the five Redis REST cache refresh jobs from GitHub Actions to Vercel Cron routes under packages/web/app/api/cron/, with a shared bearer-auth handler, Uptime Kuma heartbeats, thin CLI entrypoints for local runs, a new CI test job, and cutover docs. The auth path is timing-safe, Kuma pushes are best-effort with a timeout, and the failure path is covered by tests.
Issues
- packages/web/app/api/cron/timeseries-refresh-historical/route.ts:8 - Cron work shares the web DB pool (high) — the daily historical rebuild now runs inside the web function and drives 40 concurrent queries per batch through the same module-level
pg.Pool(max 4, 5-second acquire timeout) that serves live GraphQL and REST requests. Under GitHub Actions the job had its own process and its own pool. For the minutes it runs, every web query on that instance queues behind a cron batch, and any wait past 5 seconds fails the user-facing request. The same overlap applies at the top of each hour whenrefresh-cacheandtimeseries-refreshfire together.- Done when: cron DB work runs on a pool separate from the one the GraphQL and REST paths use — a second
Poolexported fromapp/api/db/index.tswith the same connection settings but its ownmax(e.g. from aPOSTGRES_CRON_POOL_MAXvariable) and acquire timeout, passed into the query helpers inrest/timeseries/db.ts,rest/reports/db.ts, andrest/list/db.tsby the five refresh jobs while live callers keep the default — sized so 40 in-flight queries do not trip the acquire timeout, and concurrent web requests complete without connection-timeout errors while the historical cron runs. - Provenance: 6aab748
- Done when: cron DB work runs on a pool separate from the one the GraphQL and REST paths use — a second
- packages/web/app/api/cron/handler.spec.ts:29 - Secret comparison is untested (low) — the only wrong-token test uses a token shorter than the secret, so it is rejected by the length pre-check and the actual byte comparison never runs; the whole suite would still pass if that comparison were replaced with an unconditional
true.- Done when: the handler suite fails when the secret comparison is replaced with an unconditional
true, i.e. a same-length, non-matching bearer token is asserted to yield 401 and not invoke the job. - Provenance: f3759ea
- Done when: the handler suite fails when the secret comparison is replaced with an unconditional
Verdict
REQUEST_CHANGES
How This Was Reviewed
Reviewed with the review-pr-workflow skill —
5 review lenses, each finding independently verified by claude. 0 candidate findings were refuted and dropped.
…uma non-2xx, drop duplicate ci test job
…ilent kuma opt-out
…tch runs concurrently
GraphQL/REST imported app/api/db and constructed the cron pool too. Move it to db/cron.ts so request handlers never allocate it.
Keep cron pool injection and main's mergeSnapshot + name fallback.
Stale-hook PPS suite still ran refresh-vaults.ts, which no longer bootstraps.
murderteeth
left a comment
There was a problem hiding this comment.
Summary
Moves the five Redis REST cache refresh jobs from GitHub Actions to Vercel Cron routes under packages/web/app/api/cron/, with a shared timing-safe bearer handler, Uptime Kuma heartbeats, a dedicated cron Postgres pool threaded through the db helpers, thin CLI entrypoints, and cutover docs. Both items from the prior review are resolved: cron queries no longer touch the request pool, and a same-length wrong-token case now exercises the secret comparison.
Issues
- packages/web/app/api/db/cron.spec.ts:15 - Pool config tests grep the source file instead of testing the pool (medium) — the two pool-default tests do not touch a pool. Each one calls
readFileSynconcron.tsorindex.ts, gets the TypeScript back as a string, and runsassert.matchagainst it forPOSTGRES_CRON_POOL_MAX ?? '40'andconnectionTimeoutMillis: 60_000. That is a grep. It passes if the text is anywhere in the file, including a comment or dead code, and it fails on a quote change or on moving the default intoconfig.tswhile the pool is configured identically. ThecronDbobject under test is already imported at the top of the same spec and never used by these two tests.pgexposes the resolved config ascronDb.options.maxandcronDb.options.connectionTimeoutMillis, so the real assertion is one line each.- Done when:
readFileSyncis gone from the spec; the tests assertcronDb.optionsanddb.optionsdirectly, fail when either pool has an effectivemaxorconnectionTimeoutMillisother than the documented values, and pass unchanged after a behavior-preserving rewrite of the twonew Pool({...})calls. - Provenance: 306874f
- Done when:
- packages/web/vercel.json:1 - 800s maxDuration depends on a dashboard-only setting (medium) —
timeseries-refresh-historicalandreports-refresh-historicalexportmaxDuration = 800, and the README documents that cap. 800s is only valid with Fluid compute on; without it the Pro limit is 300s and the daily historical rebuild is killed mid-run. Fluid is currently enabled in the Vercel dashboard, but nothing in the repo says so, so a project recreate, a settings change, or a second deployment of this package silently turns the cap into a 504 at minute five.- Done when:
packages/web/vercel.jsonsets"fluid": trueso the setting is code, and the README cron section states that the 800s routes require it. - Provenance: f3759ea
- Done when:
- packages/web/app/api/rest/cache.ts:6 - Redis error listener is unpinned (low) — the listener that stops an idle Redis drop from crashing a cron invocation or web instance has no test, unlike the matching pg pool listener asserted in
cron.spec.ts, so a later refactor ofcache.tscan drop it and nothing goes red. Registration is assertable without a live Redis.- Done when: a web spec fails if the
errorlistener on the REST cache write client is removed, and passes with no Redis server running. - Provenance: f3759ea
- Done when: a web spec fails if the
Verdict
REQUEST_CHANGES
How This Was Reviewed
Reviewed with the review-pr-workflow skill —
5 review lenses, each finding independently verified by claude. 1 candidate finding was refuted and dropped.
murderteeth
left a comment
There was a problem hiding this comment.
Summary
Third iteration of the GitHub Actions to Vercel Cron cutover. All three items from the prior review are resolved: pool config tests assert the live pool options, vercel.json pins Fluid compute, and the Redis error listener is covered. Bearer auth is timing-safe, Kuma pushes are best-effort, and CI plus the Vercel preview deploy are green on the head commit.
Issues
None.
Verdict
APPROVE
How This Was Reviewed
Reviewed with the review-pr-workflow skill —
5 review lenses, each finding independently verified by claude. 1 candidate finding was refuted and dropped.
Summary
Moves the five Redis REST cache refresh jobs from GitHub Actions to Vercel Cron. GitHub's
scheduletrigger fired the 30-min refresh every 1–3h in practice, so the cache went stale. Each job is now a route underpackages/web/app/api/cron/scheduled fromvercel.json, with the refresh logic exported as plain functions and thin*.cli.tsentrypoints kept for local/manual runs.Also deletes the five workflow files and documents the cutover in
packages/web/README.md.mainalready runsbun --filter web teston PRs viatest.yml(added in #442 after this branch started); this PR does not touch that workflow.How to review
packages/web/app/api/cron/handler.ts: bearer auth againstCRON_SECRET(timing-safe), runs the job, pushes up/down to Uptime Kuma, 500 on failure. A missing Kuma URL skips the push and the route still returns 200, so the monitor never alerts; a non-2xx push is logged, not raised. 401 does not touch Kuma.packages/web/app/api/db/cron.ts: crons query through their own pool (cronDb,POSTGRES_CRON_POOL_MAX, default 40, sized to the 10 vaults × 4 labels a timeseries batch queries concurrently, 60s acquire timeout). The GraphQL/REST pool stays inpackages/web/app/api/db/index.tsand does not constructcronDb.packages/web/app/api/rest/cache.ts: adds anerrorlistener on the shared Redis write client so a dropped connection logs instead of crashing the process. Affects all web Redis writes, not just crons.packages/web/app/api/cron/*/route.ts: one route per job.reports-*are manual-only (novercel.jsonentry), same as theworkflow_dispatch-only workflows they replace.packages/web/vercel.json: schedules.timeseries-refresh-historicalmoved from0 0to15 2UTC to avoid the top-of-hour pile-up withtimeseries-refresh.packages/web/app/api/rest/**/refresh*.ts: diff is mostly indentation from wrapping the body intry/finallyfor elapsed logging;require.mainbootstraps moved to*.cli.tsviarun-cli.ts..claude/skills/trace-propagation/SKILL.mdand the two scripts READMEs, path updates only.Test plan
packages/web/README.mdon the Vercel project (Production scope), deploy, thencurl -H "Authorization: Bearer $CRON_SECRET" https://<deployment>/api/cron/refresh-cacheand check the Uptime Kuma monitor flips to up.*/30run completes withinmaxDuration.bun --filter web test(handler, routes, cron-vs-request pool isolation, db helper pool passthrough, refresh jobs passingcronDb, cli 0/1 exit). New specs in this loop were checked by reading, not re-run.tsc --noEmitonpackages/webwas clean before this loop.Risk / impact
CRON_SECRETand the Postgres/Redis vars exist in Vercel every cron returns 401 or fails; missing Kuma vars do not fail the cron, they only silence its heartbeat. Set them before merging.maxDuration: 800on the historical routes needs Vercel Pro with Fluid Compute; otherwise the cap is 300s and the historical rebuild will be killed. The 300s routes (refresh-cache,timeseries-refresh) will also be killed if a run exceeds 5 min; GHA's default job timeout was 6h. A platform timeout also skips the Kuma down-push, so keep the monitor heartbeat interval tight. Hobby only fires crons daily, so*/30and hourly need Pro.gh search codeoveryearn/forrest:list:vaults,refresh-vaults.ts, andUPTIME_KUMA_PUSH_URL_REFRESH_VAULTSonly hits this repo (REST routes, ingest container specs, two script READMEs). CLI path updates are in those ingest specs. No other yearn consumer of the GHA workflows.POSTGRES_POOL_MAX(web) plusPOSTGRES_CRON_POOL_MAX(crons, default 40) must fit the Postgres connection limit across concurrent instances. Size before enabling. Cron instances still load the request pool module (db helpers default to it) but do not query it.git revertrestores the workflows; nothing in the data path changed.