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
noncethe resource or authorization server requires the client to echo is time-bounded). - DPoP proof
jtireplay records - RFC 9449 §11.1 (ajtineed only be remembered for the proofiatacceptance window; past that window the record is dead weight). - pushed authorization requests - RFC 9126 §2.2 (a
request_urireference 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 itsexpires_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_atcan authorize nothing, andconsume/2already rejects it on read). - back-channel-logout sessions - OpenID Connect Back-Channel Logout 1.0 (a
recorded
(session, RP)delivery row past itsexpires_atbelongs 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_idpast itsexpires_atyieldsexpired_tokenand can mint nothing;redeem/4already 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- theEcto.Repothe deletes run against (required byAttestoPhoenix.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_configuredmode 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
@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.
@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.
@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.
@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.
@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.
@spec sweep_now(GenServer.server()) :: %{optional(String.t()) => non_neg_integer()}
@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.
@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.