Closes #6
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user