You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Preview governance: TTL, idle teardown, caps and a connection budget #241
Nothing bounds a preview today: no TTL, no idle teardown, no cap per repo or per gateway, and no budget on the database connections previews may hold. Two consequences, both fatal once strangers point CI at an operator's box:
A forgotten open PR holds a container and a database forever. The cleanup paths are PR close (sprout teardown) and the sweep, which reconciles against the forge's open-PR list — so it cannot remove a preview whose PR is simply old and quiet.
The ceiling is invisible until it is breached. Measured: each preview app holds two long-lived pools at default size, so ~8 previews at ~12 idle connections each fill a 100-slot Postgres instance with zero traffic (remaining connection slots are reserved for SUPERUSER, SQLSTATE 53300). The recovery we used was raising max_connections — headroom, not a bound — and shrinking per-app pools depends on the adopter's app.
Proposal
Make the gateway the place where a preview's lifetime and footprint are bounded. Everything is configurable and inert for existing deployments.
Manifest keys (validated with named errors like the rest of the manifest):
preview.ttl: 7d — measured from the last successful deploy; a push refreshes it.
preview.idle_teardown: 2h — measured from the last observable activity. Choose exactly one signal and document it. The honest cheap signal is "last deploy, including reseed/reset". Do not build a per-preview access-log pipeline in this ticket; if the edge already provides usable activity at zero cost, say so, otherwise state the limitation in the docs.
Gateway env (SPROUT_*, optional group with OPTIONAL_ENV_DEFAULTS, each accepting off): SPROUT_PREVIEW_TTL, SPROUT_PREVIEW_IDLE_TEARDOWN, SPROUT_MAX_PREVIEWS_PER_REPO, SPROUT_MAX_PREVIEWS. Manifest keys override gateway defaults.
Caps fail fast and loudly. Exceeding a cap fails the deploy with a named error (preview_limit_reached) that names the cap, its value and the current count — no container and no database created. The sweep logs over-cap state.
The sweep expires; nothing else does. Extend the existing reconciliation pass (SPROUT_SWEEP_MINUTES, default 30) to expire by TTL/idle through the same code path teardown uses (stop container, drop database, update the row with its reason), taking the per-preview lock first so a preview mid-deploy is never expired. The reason is stored on the row and surfaces on the read surface.
Connection budget. Give the gateway arithmetic it can enforce instead of discovering afterwards: SPROUT_PREVIEW_MAX_DB_CONNECTIONS (expected per-preview connections) against the known instance ceiling, so a deploy beyond the projection fails with a named error (or, whichever is chosen, warns on the read surface and in the sweep log). Document the arithmetic with the measured per-preview figure from the worked case, and state plainly that the figure is only accurate for apps that honour the injected cap.
Read surface and note.GET /v1/previews and the single-preview read return expires_at, last_activity_at and the expiry reason; the CI note carries the expiry line so a reviewer can see when the preview disappears.
Defaults must not silently delete anything an existing adopter has. Default TTL/idle to off, caps to offwith a loud boot warning that the gateway is unbounded, and ship the self-serve local stack with 7d TTL on so a new operator is safe without configuring anything. The PR description states this trade-off explicitly.
Acceptance criteria
A preview untouched past its TTL is removed by the sweep with no CI run: container gone, database absent from pg_database, row updated with the expiry reason, and the next deploy provisions fresh (the seed re-runs, seeded_at cleared).
A push (or ci reset / ci reseed) before the deadline deploys normally and refreshes the deadline; the new value is visible on the read surface.
A preview whose deploy holds the per-preview lock is never expired (test holds the lock and runs the sweep).
Exceeding SPROUT_MAX_PREVIEWS or SPROUT_MAX_PREVIEWS_PER_REPO fails with preview_limit_reached naming cap, value and count, leaving no container and no database behind.
A per-repo preview.ttl overrides the gateway default and off at either level disables it (each combination tested).
Connection budget: the projection (previews × per-preview connections vs the instance ceiling) is computed from configured values, documented in docs/operator-deploy.md with the measured per-preview figure, and the chosen behaviour (refuse or warn) is asserted by a test.
With nothing configured, behaviour is identical to v0.8.0 apart from the boot warning: no expiry, no cap, no new required env for an existing deployment.
Docs: docs/previews.md owns TTL/idle semantics and names the exact activity signal; docs/adopting-a-repo.md gets the manifest keys; docs/operator-deploy.md gets the env rows, the budget arithmetic and the "unbounded by default" warning.
Scope guard
No webhook receiver and no forge event stream (ADR-0004 stands): expiry is sweep-driven plus the existing PR-close teardown.
No per-preview Postgres instance, no new service container, and no access-log pipeline unless it is genuinely already available.
No metrics/Prometheus exporter and no dashboard work — the read surface only gains fields.
No changes to adopter apps: a repo that wants a smaller pool sets it through the existing env injection, and the gateway must not assume that happened.
Drift note (2026-10-02, before kickoff): the sweep no longer uses SPROUT_SWEEP_MINUTES (default 30). PR #260 (2026-09-30) moved it to in-process Bun.cron with SPROUT_SWEEP_CRON (default */30 * * * *). Expiry hooks into that registration, its no-overlap semantics and the existing per-preview lock. The rest of the ticket (manifest keys, cap errors, connection budget, read surface) stands as written.
Problem
Nothing bounds a preview today: no TTL, no idle teardown, no cap per repo or per gateway, and no budget on the database connections previews may hold. Two consequences, both fatal once strangers point CI at an operator's box:
sprout teardown) and the sweep, which reconciles against the forge's open-PR list — so it cannot remove a preview whose PR is simply old and quiet.remaining connection slots are reserved for SUPERUSER, SQLSTATE 53300). The recovery we used was raisingmax_connections— headroom, not a bound — and shrinking per-app pools depends on the adopter's app.Proposal
Make the gateway the place where a preview's lifetime and footprint are bounded. Everything is configurable and inert for existing deployments.
preview.ttl: 7d— measured from the last successful deploy; a push refreshes it.preview.idle_teardown: 2h— measured from the last observable activity. Choose exactly one signal and document it. The honest cheap signal is "last deploy, including reseed/reset". Do not build a per-preview access-log pipeline in this ticket; if the edge already provides usable activity at zero cost, say so, otherwise state the limitation in the docs.SPROUT_*, optional group withOPTIONAL_ENV_DEFAULTS, each acceptingoff):SPROUT_PREVIEW_TTL,SPROUT_PREVIEW_IDLE_TEARDOWN,SPROUT_MAX_PREVIEWS_PER_REPO,SPROUT_MAX_PREVIEWS. Manifest keys override gateway defaults.preview_limit_reached) that names the cap, its value and the current count — no container and no database created. The sweep logs over-cap state.SPROUT_SWEEP_MINUTES, default 30) to expire by TTL/idle through the same code pathteardownuses (stop container, drop database, update the row with its reason), taking the per-preview lock first so a preview mid-deploy is never expired. The reason is stored on the row and surfaces on the read surface.SPROUT_PREVIEW_MAX_DB_CONNECTIONS(expected per-preview connections) against the known instance ceiling, so a deploy beyond the projection fails with a named error (or, whichever is chosen, warns on the read surface and in the sweep log). Document the arithmetic with the measured per-preview figure from the worked case, and state plainly that the figure is only accurate for apps that honour the injected cap.GET /v1/previewsand the single-preview read returnexpires_at,last_activity_atand the expiry reason; the CI note carries the expiry line so a reviewer can see when the preview disappears.Defaults must not silently delete anything an existing adopter has. Default TTL/idle to
off, caps tooffwith a loud boot warning that the gateway is unbounded, and ship the self-serve local stack with7dTTL on so a new operator is safe without configuring anything. The PR description states this trade-off explicitly.Acceptance criteria
pg_database, row updated with the expiry reason, and the next deploy provisions fresh (the seed re-runs,seeded_atcleared).ci reset/ci reseed) before the deadline deploys normally and refreshes the deadline; the new value is visible on the read surface.SPROUT_MAX_PREVIEWSorSPROUT_MAX_PREVIEWS_PER_REPOfails withpreview_limit_reachednaming cap, value and count, leaving no container and no database behind.preview.ttloverrides the gateway default andoffat either level disables it (each combination tested).docs/operator-deploy.mdwith the measured per-preview figure, and the chosen behaviour (refuse or warn) is asserted by a test.docs/previews.mdowns TTL/idle semantics and names the exact activity signal;docs/adopting-a-repo.mdgets the manifest keys;docs/operator-deploy.mdgets the env rows, the budget arithmetic and the "unbounded by default" warning.Scope guard
Parent
#239