pub struct SessionStore<S> { /* private fields */ }Expand description
The sessions this server currently holds open.
Generic over what a session is, because this type is a map and a map does
not need to know. The Streamable HTTP adapter instantiates it with whatever
the router keeps per session — an engine handle, a protocol state machine, a
cancellation token — and the Arc in lookup is what lets
a handler hold that state across an .await without borrowing the store.
§Example
use std::sync::Arc;
use protocol_droid::adapters::primary::transport::http::SessionStore;
use protocol_droid::application::session::IdSource;
use protocol_droid::domain::session::state::SessionId;
// A source that hands out a fixed sequence, so the test can say which id it
// expects instead of discovering it.
struct Counting(core::sync::atomic::AtomicU64);
impl IdSource for Counting {
fn next_session_id(&self) -> SessionId {
SessionId::new(
self.0.fetch_add(1, core::sync::atomic::Ordering::SeqCst) + 1,
)
}
}
let ids: Arc<dyn IdSource + Send + Sync> =
Arc::new(Counting(core::sync::atomic::AtomicU64::new(0)));
let store = SessionStore::new(ids);
let id = store.open(Arc::new("the negotiated state"))?;
// The id is a header value, so it round-trips through the header parser.
assert_eq!(id.to_string().parse(), Ok(id));
assert!(store.lookup(&id)?.is_some());
// DELETE removes it, and a later request is a miss rather than a leftover.
assert!(store.terminate(&id)?.is_some());
assert!(store.lookup(&id)?.is_none());Implementations§
Source§impl<S> SessionStore<S>
impl<S> SessionStore<S>
Sourcepub fn new(ids: Arc<dyn IdSource + Send + Sync>) -> Self
pub fn new(ids: Arc<dyn IdSource + Send + Sync>) -> Self
A store with no sessions, numbering them from ids.
Takes the source rather than constructing one: the engine already has an
IdSource for the same reason, and two sources means two sequences for
what a reader will reasonably assume is one numbering.
The store built through this constructor is unbounded. A deployment
that needs a bound constructs with
with_idle_reaper instead, which adds
the bookkeeping reap_idle reads.
Sourcepub fn with_idle_reaper(
ids: Arc<dyn IdSource + Send + Sync>,
clock: Arc<dyn Clock + Send + Sync>,
) -> Self
pub fn with_idle_reaper( ids: Arc<dyn IdSource + Send + Sync>, clock: Arc<dyn Clock + Send + Sync>, ) -> Self
A store that tracks when each session was last touched, so a
caller-driven reap_idle can evict the ones
a client has stopped driving.
The store does not start a reaper task on its own: a
background task started inside a constructor is one a deployment
cannot stop, and reaping intervals are an operator decision. The
caller is expected to call reap_idle from
whichever loop it owns (a periodic timer, a select! on shutdown,
an external cron) with the max_age it considers idle.
clock is the source of time the store reads. It is
injected rather than read from a global so a test can drive
reaping without sleeping; the rest of the crate already takes
the same shape for IdSource.
Sourcepub fn open(&self, session: Arc<S>) -> Result<McpSessionId, SessionStoreError>
pub fn open(&self, session: Arc<S>) -> Result<McpSessionId, SessionStoreError>
Registers session under a freshly minted id and returns that id.
The caller puts the id in the Mcp-Session-Id response header; this type
never formats it, because the buffer-based
McpSessionId::as_bytes is
how the header gets filled without an allocation per response.
When the store was built with
with_idle_reaper, the new entry’s
last_touched is the clock’s current reading. The recording
happens under the same lock as the insertion, so a reap_idle
that runs concurrently with an open cannot see the session
without also seeing the timestamp that would spare it.
§Errors
SessionStoreError::DuplicateId if the injected source returned an id
this store is already holding. IdSource promises not to repeat one,
so a source that has wrapped or one that was seeded twice has broken its
contract, and overwriting would hand the second caller a session
belonging to the first. One id is tried: retrying a source that is
provably out of ids is a livelock with better manners, and the error
says what to do.
SessionStoreError::Unavailable if the lock is poisoned.
Sourcepub fn open_with_id(
&self,
session: Arc<S>,
id: SessionId,
) -> Result<McpSessionId, SessionStoreError>
pub fn open_with_id( &self, session: Arc<S>, id: SessionId, ) -> Result<McpSessionId, SessionStoreError>
Opens a session under an id the caller has already taken.
Separate from Self::open because a session’s peer has to know its
own id before it is inserted — the peer is part of the value being
inserted, and everything the peer keys on (idempotency, in-flight
requests) reads the id from inside it. A store that allocated the id on
the way in could only hand the peer that id afterwards, by which point
the peer has already been constructed with the wrong one.
§Errors
As Self::open: SessionStoreError::DuplicateId if the id is
already held, SessionStoreError::Unavailable if the lock is poisoned.
Sourcepub fn next_id(&self) -> SessionId
pub fn next_id(&self) -> SessionId
The next id this store would hand out.
For a caller that must build a session around a known id before it
can hand that session to Self::open_with_id. The contract is the
same as the one open relies on: a source that repeats an id is caught
by the duplicate check there, so reading an id early is safe even
though nothing reserves it in the meantime.
Two callers racing here get two different ids and both open; the loser is refused by the duplicate check, which is the answer for a source that repeats one.
Sourcepub fn lookup(
&self,
id: &McpSessionId,
) -> Result<Option<Arc<S>>, SessionStoreError>
pub fn lookup( &self, id: &McpSessionId, ) -> Result<Option<Arc<S>>, SessionStoreError>
The session under id, if this server still has it.
A returned Arc is a snapshot of reachability, not of the session’s
contents: a concurrent DELETE can remove the map entry while this
clone is still alive and being used. That is the intended semantics —
the in-flight request finishes against the session it already found,
and only the next request sees the 404 — so callers must not treat
a Some as a promise that the entry will still be there in a moment.
When the store was built with
with_idle_reaper, a successful
lookup updates the entry’s last_touched to the clock’s
current reading. The bookkeeping is what makes a session
“idle” mean “no request has hit it for the window”, and a
request hitting it is the only signal the store has that
the client is still driving it.
§Errors
SessionStoreError::Unavailable if the lock is poisoned.
Sourcepub fn terminate(
&self,
id: &McpSessionId,
) -> Result<Option<Arc<S>>, SessionStoreError>
pub fn terminate( &self, id: &McpSessionId, ) -> Result<Option<Arc<S>>, SessionStoreError>
Removes the session under id, returning it if it was there.
A None here is the same None lookup returns, and
the router maps both to the same outcome. Deciding between “terminate
this session” and “this endpoint does not offer termination here” is
the router’s, because only the router knows what the endpoint offers.
When the store was built with
with_idle_reaper, the corresponding
last_touched is also removed: leaving it behind would be a
slow leak of a different shape (timestamps whose sessions
have already gone), and the next reap_idle would walk
past an entry that no longer has a session to evict.
§Errors
SessionStoreError::Unavailable if the lock is poisoned.
Sourcepub fn reap_idle(
&self,
max_age: Duration,
) -> Result<Vec<McpSessionId>, SessionStoreError>
pub fn reap_idle( &self, max_age: Duration, ) -> Result<Vec<McpSessionId>, SessionStoreError>
Evicts every session whose last_touched is older than max_age,
returning the ids that were reaped.
The store built through new has no reaper and
reap_idle is a no-op that returns an empty vector. The
store built through
with_idle_reaper records the time
on every open and lookup, and reap_idle compares it
against the clock’s current reading.
The caller drives reaping: a periodic timer, a select! on
shutdown, an external cron. There is no background task
started inside the store, because a background task started
inside a constructor is one a deployment cannot stop.
§Errors
SessionStoreError::Unavailable if the reaper’s lock is
poisoned, which the same rule that refuses to recover the
sessions map also applies here: answering from state this
process cannot vouch for is how one request ends up served
another’s.
Sourcepub fn len(&self) -> Result<usize, SessionStoreError>
pub fn len(&self) -> Result<usize, SessionStoreError>
How many sessions are open.
§Errors
SessionStoreError::Unavailable if the lock is poisoned. A made-up
number here would be worse than the error: this is the count an
operator reads to decide whether something is wrong.
Sourcepub fn is_empty(&self) -> Result<bool, SessionStoreError>
pub fn is_empty(&self) -> Result<bool, SessionStoreError>
Trait Implementations§
Source§impl<S> Debug for SessionStore<S>
impl<S> Debug for SessionStore<S>
Source§fn fmt(&self, formatter: &mut Formatter<'_>) -> Result
fn fmt(&self, formatter: &mut Formatter<'_>) -> Result
Prints the type’s name and nothing else.
Deliberately opaque, and the omission is the point: a derived Debug
on this struct would print the whole map, which is a table of session
ids — and the project does not put a full session id in a log line.
A {:?} in a log statement must not become a way around that.