Skip to content

Preview governance: TTL, idle teardown, caps and a connection budget #241

Description

@simpros

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:

  • 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 off with 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.

Parent

#239

Activity

  1. added this to the v0.9.0 milestone on Sep 24, 2026
  2. simpros commented on Oct 2, 2026

    @simpros
    OwnerAuthor

    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.

  3. simpros commented on Oct 2, 2026

    @simpros
    OwnerAuthor

    Closed by #265, which merged without a plain-text close keyword. Preview governance (TTL, idle teardown, caps, connection budget) is on main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions