From 39552560b8ed1a7b551d8f29e662995c20ae198c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 14:09:17 +0000 Subject: [PATCH] Stop the lease-expiry cron from pinning Neon compute at 100% The `*/5 * * * *` sweep ran expire() every 5 minutes, 24/7. Neon's serverless compute autosuspends after ~5 minutes idle, so a query on that exact cadence never let it scale to zero: the compute ran continuously (~0.25 CU x 24h x 30d ~= 180 CU-hours/month), blowing past the 100 CU-hour monthly allowance and pinning usage at 100%. The sweep is only a backstop. checkoutTask and listOpenTasks already reclaim lapsed locks lazily on the hot path (reclaimLapsedLocks), so any active use of the pool self-heals a stranded reservation within one request. The cron only does anything when the system is otherwise idle -- which is precisely when we want the compute suspended. Move the backstop to hourly (`0 * * * *`). Idle periods now let Neon autosuspend; the only cost is that a crashed runner's budget reservation in a fully idle system lingers up to ~1h instead of ~5m (the 10-minute lease itself is unchanged). Comments in wrangler.toml, worker.ts, and operations.ts updated to match. Co-Authored-By: Claude --- src/operations.ts | 9 +++++---- src/worker.ts | 12 +++++++----- wrangler.toml | 23 +++++++++++++++++------ 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/operations.ts b/src/operations.ts index 4440011..621b1ae 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -411,9 +411,10 @@ const PRIOR_CONTRIBUTIONS_LIMIT = 5; * lock_expires_at has passed is reclaimable by design, but the claim queries * guard on status='open', so until expire() runs the task is invisible and its * reservation blocks the volunteer's budget. The cron trigger runs expire() - * every 5 minutes; this makes the pool-facing reads self-healing too, so a - * lapsed lock never gates on the next cron tick. Best-effort: a failure here - * must never fail the read/checkout it piggybacks on. + * hourly (a low-frequency backstop so Neon's compute can autosuspend when idle + * — see wrangler.toml); this lazy path makes the pool-facing reads self-healing + * too, so an active pool never waits on the cron tick to reclaim a lapsed lock. + * Best-effort: a failure here must never fail the read/checkout it piggybacks on. */ async function reclaimLapsedLocks(): Promise { try { @@ -2494,7 +2495,7 @@ function pendingDecompositionSql(alias: string): string { export async function listOpenTasks(filter: OpenTaskFilter = {}): Promise { // A task under a lapsed lock belongs in this listing — reclaim before // reading so stranded work is visible to the next poll, not just to the - // 5-minute cron sweep. + // hourly cron sweep. await reclaimLapsedLocks(); const conditions: string[] = [`status = 'open'`, `NOT ${pendingDecompositionSql('tasks')}`]; const params: unknown[] = []; diff --git a/src/worker.ts b/src/worker.ts index 5df89c1..6fc9b8f 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -30,11 +30,13 @@ export default { return app.fetch(req, env as any, ctx as any); }, email: emailHandler, - // Cron trigger (wrangler.toml [triggers]): the lease-expiry sweep. A crashed - // runner never calls /release; without this, its task is stranded out of the - // pool and its reservation blocks the volunteer's budget until someone - // remembers to POST /admin/expire. Thin shim by design — all the logic (and - // its tests) live in operations.expire(). + // Cron trigger (wrangler.toml [triggers]): the hourly lease-expiry sweep. A + // crashed runner never calls /release; without this, its task is stranded out + // of the pool and its reservation blocks the volunteer's budget until someone + // remembers to POST /admin/expire. Runs hourly, not every few minutes: the + // hot path (checkout/listing) already reclaims lazily, so a frequent tick + // would buy nothing but keep Neon's compute from ever autosuspending. Thin + // shim by design — all the logic (and its tests) live in operations.expire(). scheduled: async (_controller: unknown, _env: unknown, _ctx: unknown) => { const r = await expire(); if (r.expired_count > 0) { diff --git a/wrangler.toml b/wrangler.toml index c4ba45e..77de7c1 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -90,13 +90,24 @@ enabled = true # The lease-expiry sweep must run on its own: a crashed or stranded runner never # calls /release, and without this sweep its task stays invisible to the pool -# and its reservation blocks the volunteer's budget INDEFINITELY. Every 5 -# minutes the Worker's `scheduled` handler (src/worker.ts) runs expire(), which -# returns lapsed-locked tasks to the pool and refunds their reservations. -# Checkout/listing also reclaim lazily (see operations.ts), so the cron is the -# backstop, not the only line of defense. +# and its reservation blocks the volunteer's budget INDEFINITELY. The Worker's +# `scheduled` handler (src/worker.ts) runs expire(), which returns lapsed-locked +# tasks to the pool and refunds their reservations. +# +# Cadence is HOURLY, not every-5-minutes, and that's deliberate: checkout and +# listing already reclaim lapsed locks lazily on the hot path (see +# reclaimLapsedLocks in operations.ts), so any active use of the pool self-heals +# 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. [triggers] -crons = ["*/5 * * * *"] +crons = ["0 * * * *"] # Outbound transactional email via Cloudflare Email Sending (the domain # givework.dev is onboarded there, so SPF/DKIM are aligned). The Worker sends the