AttestoPhoenix.Store.Sweeper (AttestoPhoenix v3.2.1)

Copy Markdown View Source

Periodic housekeeping GenServer that deletes expired rows from the Ecto-backed authorization-code, refresh-token, device-code, CIBA-request, logout-session, DPoP-nonce, DPoP-replay, pushed-authorization-request, client-id-metadata-cache, and consent-grant tables.

Each of these tables carries an expires_at column whose semantics are fixed by the relevant RFC:

  • authorization codes - RFC 6749 §4.1.2 ("The authorization code MUST expire shortly after it is issued") and §10.5 (codes are short-lived, single-use).
  • refresh tokens - RFC 6749 §1.5 / §6 (refresh tokens MAY expire); the stored expiry bounds the credential's lifetime.
  • server-issued DPoP nonces - RFC 9449 §8 / §9 (the nonce the resource or authorization server requires the client to echo is time-bounded).
  • DPoP proof jti replay records - RFC 9449 §11.1 (a jti need only be remembered for the proof iat acceptance window; past that window the record is dead weight).
  • pushed authorization requests - RFC 9126 §2.2 (a request_uri reference is short-lived; past its expiry it can resolve nothing).
  • cached Client ID Metadata Documents - draft-ietf-oauth-client-id-metadata-document-01 §6 / RFC 9111 (a cached document is fresh only until its expires_at; past that it is re-fetched).
  • consent grants - RFC 6749 §4.1.1 / §4.1.2 (consent precedes a short-lived authorization code; a grant past its expires_at can authorize nothing, and consume/2 already rejects it on read).
  • back-channel-logout sessions - OpenID Connect Back-Channel Logout 1.0 (a recorded (session, RP) delivery row past its expires_at belongs to an abandoned session and is no longer a logout target).
  • CIBA authentication requests - OpenID Connect CIBA Core 1.0 §7.3 (an auth_req_id past its expires_at yields expired_token and can mint nothing; redeem/4 already re-checks expiry on read).

Correctness vs. housekeeping

Expiry-row deletion is not required for authorization correctness. Every store re-validates expires_at against the current time on read, so an expired row that has not yet been swept is never honored: an expired authorization code is rejected, an expired nonce is rejected, and an expired replay record no longer blocks a fresh jti. Those deletes only bound table growth by reclaiming rows that can no longer affect any decision. A consumed code's expired row can still carry the replay-revocation link for a live access token, so that row is retained until the linked token expires.

The process also irreversibly redacts refresh-successor ciphertext whose short retry deadline has passed, on the next scheduled sweep. When the Ecto refresh store uses a positive retry grace, this bounded credential cleanup requires the packaged sweeper or an acknowledged equivalent cleanup worker. The installer adds the packaged worker to the host supervision tree automatically; manually wired applications MUST either supervise it with a positive :sweep_interval_ms or register an equivalent worker.

The remaining work is TTL housekeeping: it issues one delete per swept table using expires_at < $now. Authorization-code cleanup additionally keeps a row while its non-empty access-token link has a future access_token_expires_at, preserving replay revocation until that token dies.

Comparison boundary (fail-closed)

Row expiry uses a strict < comparison against a single DateTime captured once per sweep (DateTime.utc_now/0) and reused across every table, so a sweep applies one consistent boundary. A row whose expires_at equals "now" is retained. For an already-expired authorization-code row, however, a linked access token whose own expiry equals "now" is no longer live and does not delay cleanup. The sweeper widens no acceptance window.

Configuration

All policy is read from AttestoPhoenix.Config; nothing is hardcoded here.

  • :repo - the Ecto.Repo the deletes run against (required by AttestoPhoenix.Config).
  • :sweep_interval_ms - how often a sweep runs, in milliseconds. Manual supervision fails fast when this key is unset. The installer uses the explicit :if_configured mode so a rerun against an older or custom-store host leaves the child ignored when no interval was configured.
  • :schema_prefix - optional PostgreSQL schema applied to every delete so a host that installed the generated tables under a non-default schema sweeps the same tables it created.

The set of swept tables is fixed by the generated schema and is not host-configurable: every Ecto-backed store the library generates carries an expires_at column and is swept.

Runtime signal

Application-facing mutations through the bundled Ecto stores whose tables the sweeper maintains check cleanup-worker liveness without waiting for telemetry handlers or Logger. The sweeper's own maintenance queries do not recursively signal. Diagnostic failures never change store results; signal delivery is asynchronous and best effort.

With no registered worker, the first mutation schedules a warning and this telemetry event:

  • [:attesto_phoenix, :store, :sweeper_unsupervised] — measurements %{count: 1}; metadata %{repo: repo, schema_prefix: prefix}.

Repeated mutations for the same repository/schema pair are suppressed for one hour, then a later mutation may schedule a reminder. Registering a cleanup worker cancels a queued warning when recovery is observed before delivery. At most 1,024 repository/schema pairs are retained for diagnostics. Suppression state is in memory, so a monitoring-process restart may begin a new episode. The two exceptional signals are:

  • [:attesto_phoenix, :store, :sweeper_signal_capacity] — measurements %{count: 1}; metadata %{}. It is scheduled once while the cap remains exhausted and rearms after capacity becomes available.
  • [:attesto_phoenix, :store, :sweeper_monitor_unavailable] — measurements %{count: 1}; metadata %{repo: repo, schema_prefix: prefix}. It is scheduled at most once per lifecycle-monitor outage and rearms only after recovery.

Telemetry metadata contains identifiers only, never configuration structs, tokens, refresh-successor secrets, or client credentials.

The package isolates this best-effort machinery from the host supervision tree. If its diagnostic supervisor exhausts its restart budget, OTP reports that failure and leaves diagnostics stopped until the :attesto_phoenix application restarts; store operations continue with their original results.

The running?/0,1, verify_running!/0,1, and sweep_now/0 functions read process liveness from a registry owned by the package root supervisor, so their answers stay correct while the diagnostics supervisor restarts or remains stopped.

Summary

Functions

Registers an acknowledged host-supplied equivalent cleanup worker process for the given target.

Determines whether an AttestoPhoenix.Store.Sweeper (or an acknowledged equivalent cleanup worker) is running for the current request configuration (or application default).

Determines whether an AttestoPhoenix.Store.Sweeper (or an acknowledged equivalent cleanup worker) is running for target.

Starts the sweeper.

Runs a single sweep synchronously and returns the number of rows deleted per table. Test- and diagnostic-facing; the supervised process drives sweeps via the configured interval, not this call.

Verifies that an AttestoPhoenix.Store.Sweeper (or an acknowledged equivalent cleanup worker) is running for target (or the current request/application configuration).

Verifies that a sweeper or acknowledged equivalent cleanup worker is running for target.

Functions

register_cleanup_worker(target, pid \\ self())

@spec register_cleanup_worker(
  AttestoPhoenix.Config.t() | {module(), String.t() | nil} | keyword(),
  pid()
) :: :ok

Registers an acknowledged host-supplied equivalent cleanup worker process for the given target.

The target must be a %Config{}, {repo, schema_prefix}, [config: config], [repo: repo], or [repo: repo, schema_prefix: prefix]. Unknown, duplicate, and ambiguous options raise ArgumentError.

While the worker process remains alive, running?/1 returns true for the target and missing-sweeper warnings are suppressed. When the worker process terminates, missing-sweeper episode detection is rearmed. This API is intended for a finite set of trusted, application-owned cleanup workers registered at startup. A registration for a still-live PID is restored when the :attesto_phoenix application restarts in the same VM. Register again when the cleanup worker itself restarts or on a new node boot.

The worker PID must belong to the local node. Each node registers and monitors its own cleanup worker.

The call returns :ok only after the lifecycle monitor has acknowledged the registration. If that monitor is restarting, the call raises and the host must retry after the application recovers.

running?()

@spec running?() :: boolean()

Determines whether an AttestoPhoenix.Store.Sweeper (or an acknowledged equivalent cleanup worker) is running for the current request configuration (or application default).

Note: Observable process liveness is confirmed; supervision tree ancestry is not claimed.

running?(config)

@spec running?(
  AttestoPhoenix.Config.t()
  | {module(), String.t() | nil}
  | GenServer.server()
  | keyword()
  | nil
) :: boolean()

Determines whether an AttestoPhoenix.Store.Sweeper (or an acknowledged equivalent cleanup worker) is running for target.

target may be a %Config{}, {repo, schema_prefix}, local pid, locally registered name, or one of these keyword forms: [config: config], [repo: repo], [repo: repo, schema_prefix: prefix], [pid: pid], [name: name], [config: config, pid: pid], or [config: config, name: name]. Unknown, duplicate, and ambiguous options return false.

Note: Observable process liveness is confirmed; supervision tree ancestry is not claimed.

start_link(opts)

@spec start_link(keyword()) :: GenServer.on_start()

Starts the sweeper.

Requires a %AttestoPhoenix.Config{} under the :config key. The config's :sweep_interval_ms MUST be a positive integer; a missing or non-positive interval raises ArgumentError so a misconfigured host fails at boot instead of starting a process that never sweeps.

The installer passes :if_configured as true for upgrade compatibility. In that mode an absent interval returns :ignore, allowing an existing host that does not use the bundled Ecto stores to keep the sweeper disabled. An invalid non-nil interval still raises, and direct/manual supervision retains the fail-fast default.

sweep_now()

@spec sweep_now() :: %{optional(String.t()) => non_neg_integer()}

Runs a single sweep synchronously and returns the number of rows deleted per table. Test- and diagnostic-facing; the supervised process drives sweeps via the configured interval, not this call.

The zero-arity form resolves the packaged sweeper registered for the current request or application configuration. It raises RuntimeError when that sweeper is not registered; an acknowledged equivalent cleanup worker is not invoked as though it were this GenServer.

sweep_now(server)

@spec sweep_now(GenServer.server()) :: %{optional(String.t()) => non_neg_integer()}

verify_running!()

@spec verify_running!() :: :ok

Verifies that an AttestoPhoenix.Store.Sweeper (or an acknowledged equivalent cleanup worker) is running for target (or the current request/application configuration).

Returns :ok when verified. Raises RuntimeError with an actionable diagnostic when no registered worker is running.

verify_running!(target)

@spec verify_running!(
  AttestoPhoenix.Config.t()
  | {module(), String.t() | nil}
  | GenServer.server()
  | keyword()
  | nil
) :: :ok

Verifies that a sweeper or acknowledged equivalent cleanup worker is running for target.

Accepts the same target forms as running?/1. Returns :ok when verified; otherwise raises RuntimeError with an actionable diagnostic.