101 lines
5.4 KiB
Markdown
101 lines
5.4 KiB
Markdown
# ADR 0004: atomic ephemeral state and single-active availability
|
|
|
|
- Status: Accepted
|
|
- Date: 2026-07-16
|
|
- Tracking: #6
|
|
|
|
## Context
|
|
|
|
Listings, leases, endpoint observations, join attempts, and replay decisions must
|
|
move together. A partially committed authorization can expose an expired listing,
|
|
reuse a capability, or introduce an endpoint that was never authorized. V1 is a
|
|
single-active service, so it needs honest bounded in-memory behavior rather than
|
|
a database-shaped abstraction that implies unavailable durability or scale.
|
|
|
|
## Decision
|
|
|
|
`IEphemeralRendezvousStore` is the atomic boundary for directory, lease, presence,
|
|
attempt, endpoint, replay, revocation, and drain transitions. The v1 implementation
|
|
serializes each transition under one process-local lock. This deliberately favors
|
|
simple, auditable correctness at the initial 25,000-listing/10,000-attempt ceiling.
|
|
It retains only immutable listing data, opaque credential fingerprints, observed
|
|
endpoints, monotonic deadlines, and bounded idempotency/replay records.
|
|
|
|
Every collection has an independent configured ceiling. An operation checks all
|
|
of the capacity it needs before changing any collection. Exhaustion returns
|
|
`CapacityExceeded`; it does not evict live state, partially insert an operation,
|
|
or grow a fallback queue. Policy-provided per-owner listing and per-tenant active
|
|
attempt quotas are evaluated inside the same creation transition, so concurrent
|
|
requests cannot pass a check performed outside the store. New join authorization returns `ServiceUnavailable`
|
|
when the atomic store is unavailable and `Draining` once drain starts.
|
|
|
|
### Time and cleanup
|
|
|
|
Expiry uses an injected monotonic clock. Wall time is used only to return an
|
|
informational `ExpiresAt` value. Moving the wall clock forward or backward cannot
|
|
expire or prolong authority. Cleanup runs deterministically at the start of every
|
|
store operation and removes presence, attempts, listings, replay entries,
|
|
idempotency records, and revocations at their deadline. Removal of a listing also
|
|
removes its presence handle and every linked attempt before another caller can
|
|
observe the store.
|
|
|
|
### Concurrency and idempotency
|
|
|
|
- Listing registration and join-attempt creation use an owner-scoped idempotency
|
|
key plus a canonical request fingerprint. An exact duplicate returns the
|
|
original live result; reuse with different input returns `Conflict`; replay
|
|
after the resource has expired returns `Expired` until the bounded idempotency
|
|
record itself expires.
|
|
- Lease renewal is compare-and-swap by version. A stale renewal returns the latest
|
|
version as `Conflict`. Renew/delete races are serialized: renewal either commits
|
|
before deletion or observes the listing as absent.
|
|
- Host presence refresh is an atomic whole-endpoint replacement because NAT
|
|
mappings can legitimately change. Attempt capabilities are different: the
|
|
first endpoint bound for each role wins, an identical datagram is idempotent,
|
|
and a different replay is rejected. Introduction is consumed once atomically.
|
|
- Cancellation is checked before waiting for the lock and again after acquiring
|
|
it. A cancellation observed at either point makes no change. Once a synchronous
|
|
transition starts, it completes atomically and does not expose partial state.
|
|
|
|
### Visibility and revocation
|
|
|
|
A listing is visible or joinable only when its lease and authenticated UDP host
|
|
presence are both fresh. Public browsing is tenant/protocol scoped, excludes
|
|
unlisted sessions, and uses a stable listing-ID order with the contract page
|
|
ceiling. Revoking a listing or principal removes every listing, presence, and
|
|
attempt path in the same transition. A revocation is inserted before removal;
|
|
if the bounded revocation pool is full, the operation rejects without deleting
|
|
anything.
|
|
|
|
### Restart and graceful drain
|
|
|
|
A process restart creates a new store instance ID and starts empty. Old listing,
|
|
lease, attempt, endpoint, idempotency, and consumption state is not recovered.
|
|
Publishers must re-register; old callers receive typed `NotFound`, `Expired`, or
|
|
`ServiceUnavailable` outcomes rather than an ambiguous success. No database is
|
|
required or supported for the single-active MVP.
|
|
|
|
Drain is idempotent. It immediately rejects new registrations, attempts, and
|
|
lease extensions, while already-created attempts may bind endpoints and consume
|
|
their introduction during the configured window (at most 30 seconds). At the
|
|
deadline all active state is cleared atomically. Readiness is false while draining
|
|
or unavailable, and application shutdown starts drain before teardown.
|
|
|
|
## Future shared-store mapping
|
|
|
|
The interface uses explicit typed outcomes, TTLs, compare-and-swap versions,
|
|
idempotency records, and all-or-nothing multi-record transitions. A future Redis
|
|
implementation therefore requires authenticated transport, tenant-prefixed keys,
|
|
server-side scripts or transactions for each transition, TTLs based on the store's
|
|
authoritative time, and deterministic mediator routing. It must preserve these
|
|
semantics and pass the same contract tests before issue #18 may enable more than
|
|
one active instance.
|
|
|
|
## Consequences
|
|
|
|
- V1 has deterministic failure and restart behavior without durable gameplay state.
|
|
- A single lock is a measured capacity constraint, not a claim of horizontal scale.
|
|
- Transport and HTTP modules cannot bypass the store for authorization decisions.
|
|
- Operational code must treat `CapacityExceeded`, `Draining`, and
|
|
`ServiceUnavailable` as normal typed overload/availability outcomes.
|