fix(crud): preserve joined_at for active session peers (#1059)

* fix(crud): preserve joined_at for active session peers

Re-adding an already-active peer no longer advances the membership
window, so peer_perspective search keeps messages from the original
join. Genuine rejoins still start a new window.

* docs: document set_peers membership window and wrap test docstrings

* fix: preserve session observer limit

---------

Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
This commit is contained in:
Aakash Kattelu 2026-08-25 10:51:59 -04:00 committed by GitHub
parent 5823f0fae9
commit 4492f66bca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 499 additions and 60 deletions

View File

@ -1050,25 +1050,31 @@ async def set_peers_for_session(
peer_names: dict[str, schemas.SessionPeerConfig],
) -> list[models.SessionPeer]:
"""
Set peers for a session, overwriting any existing peers.
If peers don't exist, they will be created.
Replace a session's ordinary peer set with ``peer_names``.
Active members keep their joined_at but take the incoming configuration:
this is a replace, so the caller's map is the desired end state. Departed
members rejoin with the incoming configuration. Scope memberships are
preserved.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
peer_names: Set of peer names to set for the session
peer_names: Mapping of peer names to session-level configuration
Returns:
List of SessionPeer objects for all peers in the session
Raises:
ResourceNotFoundException: If the session does not exist
ObserverException: If the resulting peer set would exceed the observer
limit
"""
# Validate observer limit before making any changes
observer_count = count_observers_in_config(peer_names)
if observer_count > settings.SESSION_OBSERVERS_LIMIT:
raise ObserverException(session_name, observer_count)
# No observer pre-check here: an already-active membership keeps its stored
# configuration, so the incoming map is not what lands. Counting it would
# reject a request that lowers the observer count as often as one that raises
# it. _get_or_add_peers_to_session enforces the limit on the resulting rows.
# Verify session exists
stmt = (
@ -1084,20 +1090,21 @@ async def set_peers_for_session(
f"Session {session_name} not found in workspace {workspace_name}"
)
# Soft delete every *ordinary* active membership. Scope memberships are
# deliberately preserved: this route replaces the peers the caller names, and a
# caller detaches a scope by simply *omitting* it from an otherwise valid
# replacement map — never naming it, so no request-level guard can see it.
# Without the exclusion a plain replacement silently bypasses the facade that
# owns scope membership and its removal reconciliation. Being part of the
# UPDATE, this holds regardless of the request body or concurrent scope
# creation.
# Soft delete every *ordinary* active membership not in the incoming map.
# Scope memberships are deliberately preserved: this route replaces the peers
# the caller names, and a caller detaches a scope by simply *omitting* it from
# an otherwise valid replacement map — never naming it, so no request-level
# guard can see it. Without the exclusion a plain replacement silently
# bypasses the facade that owns scope membership and its removal
# reconciliation. Being part of the UPDATE, this holds regardless of the
# request body or concurrent scope creation.
update_stmt = (
update(models.SessionPeer)
.where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only update active peers
models.SessionPeer.peer_name.notin_(peer_names.keys()),
~exists(
select(models.Peer.id)
.where(models.Peer.workspace_name == workspace_name)
@ -1118,12 +1125,14 @@ async def set_peers_for_session(
)
_reject_resolved_scope_peers(peers_result.resource)
# Add new peers to session
# Add new peers to session. This route replaces the session's peer set, so the
# incoming configuration is authoritative even for an already-active member.
peers = await _get_or_add_peers_to_session(
db,
workspace_name=workspace_name,
session_name=session_name,
peer_names=peer_names,
replace_config=True,
)
await db.commit()
@ -1162,13 +1171,22 @@ async def _get_or_add_peers_to_session(
peer_names: dict[str, schemas.SessionPeerConfig],
*,
fetch_after_upsert: bool = True,
replace_config: bool = False,
) -> list[models.SessionPeer]:
"""
Upsert session-peer memberships for a session and optionally fetch the
active memberships afterward.
New peers are inserted, peers that previously left the session are rejoined,
and already-active peers keep their existing session-level configuration.
and already-active peers keep their existing joined_at.
An already-active peer also keeps its stored configuration unless
``replace_config`` is set: an add must not overwrite configuration it was
never asked about, while a replace states the desired end state.
The observer limit is checked against the rows the upsert actually produced,
not against the incoming map, since under the add semantics the incoming map
is not necessarily what lands.
Args:
db: Database session
@ -1177,13 +1195,17 @@ async def _get_or_add_peers_to_session(
peer_names: Mapping of peer names to session-level configuration
fetch_after_upsert: If True, query and return the active session peers
after the upsert. If False, skip that read and return an empty list.
replace_config: If True, an already-active membership takes the incoming
configuration instead of keeping its stored one. Set by replace-style
callers; leave False for add-style callers.
Returns:
Active SessionPeer objects after the upsert, or an empty list when the
post-upsert fetch is skipped
Raises:
ObserverException: If adding peers would exceed the observer limit
ObserverException: If the resulting active peer set would exceed the
observer limit
"""
# If no peers to add, skip the insert and just return existing active session peers
if not peer_names:
@ -1202,43 +1224,10 @@ async def _get_or_add_peers_to_session(
# costs document rows, not LLM calls, and counting them would
# cap scopes-per-session at SESSION_OBSERVERS_LIMIT and surface as an
# observer-shaped 400 through a facade that hides observers entirely.
# Resolved up front because the limit check below gates on whether this
# request asks for a *non-scope* observer.
scopes_being_added = await scope_peer_names(db, workspace_name, peer_names.keys())
# Only validate observer limit if we're adding non-scope peers with observe_others=True
new_observer_count = count_observers_in_config(
{n: c for n, c in peer_names.items() if n not in scopes_being_added}
)
if new_observer_count > 0:
# Use a single efficient query to count existing observers not being updated
# This uses PostgreSQL's JSONB operators to check the observe_others field directly
existing_observers_stmt = select(func.count()).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only active peers
models.SessionPeer.peer_name.notin_(
peer_names.keys()
), # Exclude peers being updated
models.SessionPeer.configuration["observe_others"].astext.cast(
Boolean
), # Only observers
# Existing scope memberships are excluded for the same reason as above.
~exists(
select(models.Peer.id)
.where(models.Peer.workspace_name == workspace_name)
.where(models.Peer.name == models.SessionPeer.peer_name)
.where(scope_peer_clause())
.correlate(models.SessionPeer)
),
)
result = await db.execute(existing_observers_stmt)
existing_observer_count = result.scalar() or 0
total_observers = existing_observer_count + new_observer_count
if total_observers > settings.SESSION_OBSERVERS_LIMIT:
raise ObserverException(session_name, total_observers)
# Use upsert to handle both new peers and rejoining peers
stmt = pg_insert(models.SessionPeer).values(
[
@ -1254,15 +1243,27 @@ async def _get_or_add_peers_to_session(
]
)
# On conflict, update joined_at and clear left_at (rejoin scenario)
# If left_at is not None (peer has left the session): Use the new configuration (stmt.excluded.configuration)
# If left_at is None (peer is still active): Keep the existing configuration (models.SessionPeer.configuration)
# On conflict, rejoin departed peers. joined_at always survives on an active
# membership -- advancing it would move the peer_perspective search window
# past messages the peer was present for (issue #940).
#
# Configuration depends on the caller's semantics. An add ("ensure this peer
# is here") must not silently overwrite a config it never asked about, so an
# active membership keeps its stored one. A replace ("these are the session's
# peers, configured thus") states a desired end state, so the incoming config
# wins -- otherwise PUT /peers could never change the configuration of a peer
# already in the session.
stmt = stmt.on_conflict_do_update(
index_elements=["session_name", "peer_name", "workspace_name"],
set_={
"joined_at": func.now(),
"joined_at": case(
(models.SessionPeer.left_at.is_not(None), func.now()),
else_=models.SessionPeer.joined_at,
),
"left_at": None,
"configuration": case(
"configuration": stmt.excluded.configuration
if replace_config
else case(
(models.SessionPeer.left_at.is_not(None), stmt.excluded.configuration),
else_=models.SessionPeer.configuration,
),
@ -1270,6 +1271,49 @@ async def _get_or_add_peers_to_session(
)
await db.execute(stmt)
# Enforce the observer limit on the resulting rows rather than predicting them.
# Under add semantics an already-active membership keeps its stored
# configuration (see the CASE above), so the incoming config is not what lands
# and cannot be counted: predicting from it silently undercounts preserved
# observers and lets a session grow past the limit indefinitely by re-sending
# its current observers at a lower config alongside new ones. Counting after
# the upsert is correct under both configuration semantics and cannot desync
# from those branches. Raising here rolls the upsert back: ObserverException
# is never caught, and both get_db and tracked_db roll back on exception.
#
# Gated on the request actually asking for a non-scope observer so that a
# session already over the limit keeps behaving as it does today: it can
# still take non-observers and scope attachments, and only a request that
# would make it worse is rejected.
if any(
config.observe_others
for peer_name, config in peer_names.items()
if peer_name not in scopes_being_added
):
observer_count = (
await db.scalar(
select(func.count()).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only active peers
models.SessionPeer.configuration["observe_others"].astext.cast(
Boolean
), # Only observers
# Scope memberships are excluded for the reason given above.
~exists(
select(models.Peer.id)
.where(models.Peer.workspace_name == workspace_name)
.where(models.Peer.name == models.SessionPeer.peer_name)
.where(scope_peer_clause())
.correlate(models.SessionPeer)
),
)
)
or 0
)
if observer_count > settings.SESSION_OBSERVERS_LIMIT:
raise ObserverException(session_name, observer_count)
if not fetch_after_upsert:
return []

View File

@ -1,14 +1,340 @@
from datetime import datetime, timezone
import pytest
from nanoid import generate as generate_nanoid
from sqlalchemy import Boolean, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models, schemas
from src.exceptions import ResourceNotFoundException
from src.config import settings
from src.exceptions import ObserverException, ResourceNotFoundException
class TestSessionCRUD:
"""Test suite for session CRUD operations"""
@pytest.mark.asyncio
async def test_get_or_create_session_preserves_active_joined_at(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Active re-adds keep joined_at and config; a genuine rejoin starts a
new window."""
test_workspace, test_peer = sample_data
session_name = str(generate_nanoid())
original_config = schemas.SessionPeerConfig(
observe_others=True, observe_me=False
)
updated_config = schemas.SessionPeerConfig(
observe_others=False, observe_me=True
)
session_peer_stmt = select(
models.SessionPeer.joined_at,
models.SessionPeer.left_at,
models.SessionPeer.configuration,
).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.peer_name == test_peer.name,
models.SessionPeer.workspace_name == test_workspace.name,
)
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=session_name, peers={test_peer.name: original_config}
),
test_workspace.name,
)
first_joined_at, first_left_at, first_config = (
await db_session.execute(session_peer_stmt)
).one()
assert first_left_at is None
assert first_config == original_config.model_dump()
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=session_name, peers={test_peer.name: updated_config}
),
test_workspace.name,
)
second_joined_at, second_left_at, second_config = (
await db_session.execute(session_peer_stmt)
).one()
assert second_joined_at == first_joined_at
assert second_left_at is None
assert second_config == original_config.model_dump()
session_peer = (
await db_session.execute(
select(models.SessionPeer).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.peer_name == test_peer.name,
models.SessionPeer.workspace_name == test_workspace.name,
)
)
).scalar_one()
session_peer.left_at = datetime.now(timezone.utc)
await db_session.commit()
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=session_name, peers={test_peer.name: updated_config}
),
test_workspace.name,
)
rejoined_joined_at, rejoined_left_at, rejoined_config = (
await db_session.execute(session_peer_stmt)
).one()
assert rejoined_joined_at > second_joined_at
assert rejoined_left_at is None
assert rejoined_config == updated_config.model_dump()
@pytest.mark.asyncio
async def test_set_peers_preserves_active_joined_at(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""PUT-peers keeps active membership windows and refreshes real rejoins."""
test_workspace, test_peer = sample_data
session_name = str(generate_nanoid())
original_config = schemas.SessionPeerConfig(
observe_others=True, observe_me=False
)
updated_config = schemas.SessionPeerConfig(
observe_others=False, observe_me=True
)
db_session.add(
models.Session(name=session_name, workspace_name=test_workspace.name)
)
await db_session.flush()
session_peer_stmt = select(
models.SessionPeer.joined_at,
models.SessionPeer.left_at,
models.SessionPeer.configuration,
).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.peer_name == test_peer.name,
models.SessionPeer.workspace_name == test_workspace.name,
)
await crud.set_peers_for_session(
db_session,
workspace_name=test_workspace.name,
session_name=session_name,
peer_names={test_peer.name: original_config},
)
first_left_at, first_config = (
await db_session.execute(
select(
models.SessionPeer.left_at,
models.SessionPeer.configuration,
).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.peer_name == test_peer.name,
models.SessionPeer.workspace_name == test_workspace.name,
)
)
).one()
assert first_left_at is None
assert first_config == original_config.model_dump()
session_peer = (
await db_session.execute(
select(models.SessionPeer).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.peer_name == test_peer.name,
models.SessionPeer.workspace_name == test_workspace.name,
)
)
).scalar_one()
session_peer.joined_at = datetime(2020, 1, 1, tzinfo=timezone.utc)
await db_session.commit()
await crud.set_peers_for_session(
db_session,
workspace_name=test_workspace.name,
session_name=session_name,
peer_names={test_peer.name: updated_config},
)
active_joined_at, active_left_at, active_config = (
await db_session.execute(session_peer_stmt)
).one()
assert active_joined_at == datetime(2020, 1, 1, tzinfo=timezone.utc)
assert active_left_at is None
# A replace states the desired end state, so the incoming config lands even
# though the membership window is untouched.
assert active_config == updated_config.model_dump()
await crud.set_peers_for_session(
db_session,
workspace_name=test_workspace.name,
session_name=session_name,
peer_names={},
)
await crud.set_peers_for_session(
db_session,
workspace_name=test_workspace.name,
session_name=session_name,
peer_names={test_peer.name: updated_config},
)
rejoined_joined_at, rejoined_left_at, rejoined_config = (
await db_session.execute(session_peer_stmt)
).one()
assert rejoined_joined_at > active_joined_at
assert rejoined_left_at is None
assert rejoined_config == updated_config.model_dump()
@pytest.mark.asyncio
async def test_observer_limit_counts_preserved_config(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
monkeypatch: pytest.MonkeyPatch,
):
"""On the add path an already-active observer keeps its stored config, so
it still counts against the limit when re-sent as a non-observer."""
monkeypatch.setattr(settings, "SESSION_OBSERVERS_LIMIT", 2)
test_workspace, _ = sample_data
# Bound to a local: rollback below expires the ORM instance, and reloading
# it would lazy-load outside the greenlet context.
workspace_name = test_workspace.name
session_name = str(generate_nanoid())
observer = schemas.SessionPeerConfig(observe_others=True, observe_me=False)
bystander = schemas.SessionPeerConfig(observe_others=False, observe_me=True)
existing = [str(generate_nanoid()) for _ in range(2)]
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=session_name, peers=dict.fromkeys(existing, observer)
),
workspace_name,
)
# Adding cannot demote an active member, so re-sending the two observers as
# non-observers leaves them observing and the third peer makes three.
with pytest.raises(ObserverException):
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=session_name,
peers={
**dict.fromkeys(existing, bystander),
str(generate_nanoid()): observer,
},
),
workspace_name,
)
# The rejected request left nothing behind.
await db_session.rollback()
observer_count = await db_session.scalar(
select(func.count()).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None),
models.SessionPeer.configuration["observe_others"].astext.cast(Boolean),
)
)
assert observer_count == 2
@pytest.mark.asyncio
async def test_set_peers_observer_limit_counts_replaced_config(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
monkeypatch: pytest.MonkeyPatch,
):
"""The replace path applies the incoming config, so demoting active
observers frees room under the limit in the same request."""
monkeypatch.setattr(settings, "SESSION_OBSERVERS_LIMIT", 2)
test_workspace, _ = sample_data
workspace_name = test_workspace.name
session_name = str(generate_nanoid())
db_session.add(models.Session(name=session_name, workspace_name=workspace_name))
await db_session.flush()
observer = schemas.SessionPeerConfig(observe_others=True, observe_me=False)
bystander = schemas.SessionPeerConfig(observe_others=False, observe_me=True)
existing = [str(generate_nanoid()) for _ in range(2)]
await crud.set_peers_for_session(
db_session,
workspace_name=workspace_name,
session_name=session_name,
peer_names=dict.fromkeys(existing, observer),
)
# Demoting both active observers while adding a new one leaves exactly one.
await crud.set_peers_for_session(
db_session,
workspace_name=workspace_name,
session_name=session_name,
peer_names={
**dict.fromkeys(existing, bystander),
str(generate_nanoid()): observer,
},
)
observer_count = await db_session.scalar(
select(func.count()).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None),
models.SessionPeer.configuration["observe_others"].astext.cast(Boolean),
)
)
assert observer_count == 1
@pytest.mark.asyncio
async def test_observer_limit_lets_over_limit_session_take_non_observers(
self,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
monkeypatch: pytest.MonkeyPatch,
):
"""A session already past the limit still accepts non-observers, so
sessions that grew over it before enforcement do not become unusable."""
monkeypatch.setattr(settings, "SESSION_OBSERVERS_LIMIT", 2)
test_workspace, _ = sample_data
workspace_name = test_workspace.name
session_name = str(generate_nanoid())
observer = schemas.SessionPeerConfig(observe_others=True, observe_me=False)
bystander = schemas.SessionPeerConfig(observe_others=False, observe_me=True)
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=session_name,
peers=dict.fromkeys(
[str(generate_nanoid()) for _ in range(2)], observer
),
),
workspace_name,
)
# Now the limit is below what the session already holds.
monkeypatch.setattr(settings, "SESSION_OBSERVERS_LIMIT", 1)
await crud.get_or_create_session(
db_session,
schemas.SessionCreate(
name=session_name, peers={str(generate_nanoid()): bystander}
),
workspace_name,
)
active_count = await db_session.scalar(
select(func.count()).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None),
)
)
assert active_count == 3
@pytest.mark.asyncio
async def test_get_session_peer_configuration(
self,

View File

@ -4,9 +4,10 @@ import datetime
import pytest
from nanoid import generate as generate_nanoid
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models
from src import crud, models, schemas
from src.utils.search import search
@ -704,3 +705,71 @@ async def test_grep_messages_observer_scoping_left_session_still_visible(
matched_ids = [m.public_id for matches, _ in results for m in matches]
assert msg_during.public_id in matched_ids
assert msg_after.public_id in matched_ids
@pytest.mark.asyncio
async def test_peer_perspective_search_after_active_readd(
db_session: AsyncSession,
):
"""Active re-add keeps existing messages visible; a genuine rejoin starts a
new window."""
workspace = models.Workspace(name=generate_nanoid())
peer1 = models.Peer(name="peer1", workspace_name=workspace.name)
peer2 = models.Peer(name="peer2", workspace_name=workspace.name)
session = models.Session(name="session1", workspace_name=workspace.name)
db_session.add_all([workspace, peer1, peer2, session])
await db_session.flush()
past_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
hours=1
)
await db_session.execute(
models.session_peers_table.insert().values(
workspace_name=workspace.name,
session_name=session.name,
peer_name=peer1.name,
joined_at=past_time,
left_at=None,
)
)
msg_old = models.Message(
content="old persistent message",
session_name=session.name,
peer_name=peer2.name,
workspace_name=workspace.name,
seq_in_session=1,
created_at=past_time + datetime.timedelta(minutes=1),
)
db_session.add(msg_old)
await db_session.commit()
session_create = schemas.SessionCreate(
name=session.name,
peers={peer1.name: schemas.SessionPeerConfig()},
)
await crud.get_or_create_session(db_session, session_create, workspace.name)
results = await search(
"persistent",
filters={"peer_perspective": peer1.name, "workspace_id": workspace.name},
limit=10,
)
assert msg_old.public_id in [m.public_id for m in results]
await db_session.execute(
update(models.SessionPeer)
.where(
models.SessionPeer.session_name == session.name,
models.SessionPeer.peer_name == peer1.name,
models.SessionPeer.workspace_name == workspace.name,
)
.values(left_at=datetime.datetime.now(datetime.timezone.utc))
)
await db_session.commit()
await crud.get_or_create_session(db_session, session_create, workspace.name)
results = await search(
"persistent",
filters={"peer_perspective": peer1.name, "workspace_id": workspace.name},
limit=10,
)
assert msg_old.public_id not in [m.public_id for m in results]