Skip to content

symfonic.services.conversation.session

session

The session service: identity issuance, activity, and tenant partitioning.

Policy lives here; storage lives behind :class:SessionStorePort. The legacy manager fused the two, which is why "sessions are in-memory only" was a property of the policy rather than a property of a store an adopter could replace.

Three legacy behaviors are contracts, not implementation details, and are preserved exactly: tenant-partitioned storage, LRU eviction of the oldest decile at the cap, and refusal of a cross-tenant session-id collision by issuing a fresh id rather than by joining the other tenant's session.

InMemorySessionStore

InMemorySessionStore()

The default store: partitioned by tenant, with a reverse owner index.

The reverse index is what makes a cross-tenant collision detectable without scanning every tenant — the legacy manager needed the same thing and it is the reason the storage layout is two maps rather than one.

Source code in src/symfonic/services/conversation/session.py
def __init__(self) -> None:
    self._rows: dict[str, dict[str, SessionRecord]] = {}
    self._owners: dict[str, str] = {}

SessionService

SessionService(*, store: SessionStorePort | None = None, clock: object | None = None, max_per_tenant: int = MAX_SESSIONS_PER_TENANT)

Creates, finds, and ages session rows for one deployment.

Source code in src/symfonic/services/conversation/session.py
def __init__(
    self,
    *,
    store: SessionStorePort | None = None,
    clock: object | None = None,
    max_per_tenant: int = MAX_SESSIONS_PER_TENANT,
) -> None:
    self._store = store if store is not None else InMemorySessionStore()
    self._clock = clock if clock is not None else _SystemClock()
    self._max_per_tenant = max_per_tenant

ensure

ensure(tenant_id: str, session_id: str | None) -> str

Return the caller's session, or issue one.

A session id already owned by a different tenant is never joined: the asking tenant gets a fresh id and the owner's row is untouched. Guessing another tenant's id must not be a way into their session.

Source code in src/symfonic/services/conversation/session.py
def ensure(self, tenant_id: str, session_id: str | None) -> str:
    """Return the caller's session, or issue one.

    A session id already owned by a *different* tenant is never joined:
    the asking tenant gets a fresh id and the owner's row is untouched.
    Guessing another tenant's id must not be a way into their session.
    """
    if session_id is None:
        return self.create(tenant_id)
    owner = self._store.owner_of(session_id)
    if owner == tenant_id:
        return session_id
    if owner is None:
        self._register(tenant_id, session_id)
        return session_id
    logger.warning(
        "session id collision refused (requested tenant=%s, owner=%s)",
        tenant_id,
        owner,
    )
    return self.create(tenant_id)

list

list(tenant_id: str) -> tuple[SessionRecord, ...]

Newest activity first, matching the legacy listing order.

Source code in src/symfonic/services/conversation/session.py
def list(self, tenant_id: str) -> tuple[SessionRecord, ...]:
    """Newest activity first, matching the legacy listing order."""
    return tuple(
        sorted(
            self._store.list(tenant_id),
            key=lambda record: record.last_active,
            reverse=True,
        )
    )

touch

touch(tenant_id: str, session_id: str) -> None

Advance activity and count a message. A foreign id is a no-op.

Source code in src/symfonic/services/conversation/session.py
def touch(self, tenant_id: str, session_id: str) -> None:
    """Advance activity and count a message. A foreign id is a no-op."""
    record = self._store.get(tenant_id, session_id)
    if record is None:
        return
    self._store.put(record.touched(self._clock.now()))

SessionStorePort

Bases: Protocol

Where session rows live. One tenant-scoped map, four verbs.

owner_of

owner_of(session_id: str) -> str | None

Which tenant holds this id, for cross-tenant collision refusal.

Source code in src/symfonic/services/conversation/session.py
def owner_of(self, session_id: str) -> str | None:
    """Which tenant holds this id, for cross-tenant collision refusal."""