Skip to content

fix(store): make label seeding and session-state writes atomic - #3407

Closed
andrewreid wants to merge 1 commit into
omnigent-ai:mainfrom
andrewreid:upstream/store-write-primitives
Closed

fix(store): make label seeding and session-state writes atomic#3407
andrewreid wants to merge 1 commit into
omnigent-ai:mainfrom
andrewreid:upstream/store-write-primitives

Conversation

@andrewreid

Copy link
Copy Markdown
Contributor

Related issue

Closes #3402

Summary

Two writes on ConversationStore decided what to write from a snapshot read before the write, rather than inside the write's own transaction, and each silently loses a concurrent update as a result.

ELI5: two tool calls on the same session each read "the counter is 3", each add 1 in their head, each write back "4" — the database ends up at 4, not 5, and nothing ever complains.

Thread A                          Thread B
read state {n: 3}
                                   read state {n: 3}
compute n+1 = 4
                                   compute n+1 = 4
write {n: 4}
                                   write {n: 4}   <- clobbers A's write silently
  1. Label seeding lost a concurrent policy write. Initial-label seeding read current labels, computed which declared defaults were missing, then upserted those. A policy write landing between the read and the upsert was overwritten back to its initial value. seed_labels_if_absent now does insert-if-absent in one statement, so the database — not a prior Python snapshot — decides which keys are missing, and a concurrent write survives.
  2. Session-state updates lost concurrent increments (the race above). State was persisted by writing the whole blob back from the caller's in-memory snapshot; two overlapping writers clobbered each other. mutate_session_state applies the caller's change inside a locked read-merge-write — SELECT ... FOR UPDATE where the dialect supports it, BEGIN IMMEDIATE on SQLite to take the write lock before the first read — so overlapping writers serialize instead of losing an update. The policy engine's in-memory hot-cache merge was reworked alongside this: it now tracks which keys the current batch of operations actually deleted, and only excludes those from what survives the old cache, rather than a blanket union (which can't tell "missing because deleted" from "missing because never part of this row" — a sub-agent's root-inherited approval key is never part of its own row at all).
  3. Neither path distinguished "the row is gone" from "the row is empty". increment_session_usage treated an absent conversation row as an empty one — an UPDATE matching nothing, with the mutated total returned as if persisted. Label seeding had the sharper version: labels carry no foreign key, so seeding against a gone conversation left an orphan row behind. Both now raise ConversationNotFoundError, existence checked in the same transaction as the write, for the same reason the insert is insert-if-absent — a check made earlier can already be stale. The contract is stated on the abstraction itself, so an out-of-tree implementation can't phantom-write while nominally conforming.

Two callers absorb that new exception rather than propagate it, and they ship here because the primitive that raises owns its callers' handling: usage accumulation on the relay path, and the cost-ask checkpoint mirrored to a session's tree root. A session deleted mid-turn has nothing to bill, and a lost approval checkpoint just re-prompts — neither should fail an in-flight streaming turn.

BREAKING: ConversationStore gains two abstract methods (seed_labels_if_absent, mutate_session_state), so an out-of-tree subclass must implement them before upgrading. There's no compatible default — the whole point of both is that the decision happens inside one statement, and a base-class fallback built from the existing primitives would reintroduce exactly the race being removed. SqlAlchemyConversationStore is the only in-tree implementation.

Test Plan

uv run --no-sync pytest tests/stores/test_conversation_store.py tests/runtime/policies/test_builder.py tests/runtime/policies/test_session_cost_ask_routing.py tests/server/integration/test_sessions_endpoints.py -q
uv run --no-sync ruff check omnigent/stores/conversation_store omnigent/runtime/policies omnigent/server/routes/_sessions
uv run --no-sync ruff format --check omnigent/stores/conversation_store omnigent/runtime/policies omnigent/server/routes/_sessions

Also ran the fresh benchmark pass from this round (dev/benchmarks/omnigent) on the write-adjacent journeys, to confirm the atomicity fix doesn't cost meaningful latency: create_session 45.3→48.0 ms SQLite / 21.5→22.8 ms Postgres, add_comment flat on both. Small, consistent, and expected — one existence-check query where the old unconditional upsert had none.

Demo

N/A — backend/store change, no UI surface.

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

Every oracle here is paired with the mutation that kills it. The seeding race is constructed rather than simulated — a store proxy commits a competing write while the helper reads its snapshot. The state race runs two threads whose transactions overlap via a threading.Barrier; removing PostgreSQL's row lock loses an increment, and so does replacing SQLite's BEGIN IMMEDIATE with a deferred session. Skipping the existence check leaves an orphan label row the test then finds. Reverting the cost-ask mirror to snapshot-then-write is caught by observing which primitive the engine reaches for, since the visible outcome is otherwise identical. A delete of a session's own state key, a delete of a sub-agent's inherited approval key, and a delete of that same key name on a top-level session are each pinned separately, since an exemption scoped by key name instead of by which keys the call actually deleted gets the last of those three wrong. Manual verification is the benchmark pass noted above, run against this branch specifically.

Changelog

Fixed a race where concurrent tool calls or policy writes on the same session could silently lose a label or session-state update instead of both landing.

Three writes on the conversation store decided from a snapshot taken before
the write, and lost or invented data when anything landed in between.

Label seeding read the current labels, worked out which declared initials
were missing, and upserted those. A policy write landing between the read and
the upsert was overwritten back to its initial value. seed_labels_if_absent
does insert-if-absent in one statement instead, so the database decides which
keys are missing and a concurrent write survives.

Session state was persisted by writing the whole blob back from the caller's
snapshot. Two parallel tool calls on one session each held their own copy, so
the second overwrote the first — two policies each incrementing a counter by
one persisted one. mutate_session_state applies the caller's change inside a
locked read-merge-write, serialised by SELECT ... FOR UPDATE where the dialect
supports it and by BEGIN IMMEDIATE on SQLite, which takes the write lock
before the first read.

All three share one row-missing contract: a write that reads before it writes
must not treat an absent row as empty. increment_session_usage did — absent
row treated as empty, an UPDATE matching nothing, and the mutated total
returned as persisted. Label seeding did too, differently: labels carry no
foreign key, so seeding for a conversation that is gone left orphan rows and
reported a snapshot nothing can read back. Both now raise, existence being
checked in the same transaction as the write for the same reason the insert
is insert-if-absent — a check the caller made earlier can already be stale.
The contract is stated on the abstraction, so another implementation cannot
phantom-write while nominally conforming.

Two callers absorb that exception rather than propagate it, and they ship
here because the primitive that raises owns its callers' handling: usage
accumulation on the relay path and the cost-ask checkpoint mirrored to a tree
root. A session deleted mid-turn has nothing to bill and a lost checkpoint
re-prompts, so neither should fail a streaming turn.

The engine's hot-state cache had the same "absence means something, but which
thing" problem one layer up. apply_state_updates merges a caller's change into
the persisted row and folds the authoritative result onto the in-memory cache
— but a key the caller just DELETEd is, correctly, simply absent from that
result, and a blanket union with the old cache put it right back: the delete
took effect in the database and nowhere else, and the next evaluation in the
same engine instance saw the pre-delete value again. The fix tracks which keys
this batch of operations actually deleted, and only those are excluded from
what survives from the old cache — a key missing from the merged result for
any other reason (a sub-agent's inherited root-approval key, which is never
part of its own row; a value seeded straight into the engine without ever
being persisted) still survives, exactly as before.

BREAKING: ConversationStore gains two abstract methods, so an out-of-tree
subclass must implement them before upgrading. There is no compatible
default — the whole point of both is that the decision happens inside one
statement, and a base-class fallback built from the existing primitives would
reintroduce exactly the race and the lost update being removed.
SqlAlchemyConversationStore is the only in-tree implementation.

Each oracle here is paired with the mutation that kills it. The seeding race
is constructed rather than simulated: a store proxy commits a competing write
while the helper reads its snapshot. The state race runs two threads whose
transactions overlap; removing PostgreSQL's row lock loses an increment, and
so does replacing SQLite's BEGIN IMMEDIATE with a deferred session. Skipping
the existence check leaves orphan label rows the test then finds. Reverting
the cost-ask mirror to snapshot-then-write is caught by observing which
primitive the engine reaches for, since the visible outcome is identical
either way. A delete of a session's own state key, a delete of a sub-agent's
inherited approval key, and a delete of that same key name on a top-level
session are each pinned separately, since an exemption scoped by key name
instead of by which keys this call deleted gets the last of those three wrong.

Signed-off-by: Andrew Reid <andrew@reid.ee>
@github-actions

Copy link
Copy Markdown
Contributor

@andrewreid This PR is a Bug fix, Feature, or UI / frontend change but the Demo section is missing or only contains a placeholder.

These change types require a screenshot or screen recording so reviewers can see the new behaviour without checking out the branch. Please update the Demo section with:

  • A screenshot or screen recording of the change, or
  • A link to a hosted video or GIF showing the new behaviour.

Use N/A only when the change has no user-visible effect whatsoever (e.g. a pure refactor or test-only change). If that's the case, uncheck the relevant type box and check Refactor / chore or Test / CI instead.

@github-actions github-actions Bot added the needs-demo PR needs a demo screenshot or recording label Jul 28, 2026
@dhruv0811
dhruv0811 removed the request for review from SabhyaC26 August 4, 2026 17:55
@dhruv0811
dhruv0811 requested a review from aravind-segu August 4, 2026 17:55
@serena-ruan serena-ruan added the P1-high Priority: major feature broken, no workaround label Aug 6, 2026
@github-actions github-actions Bot added P3-low Priority: minor issue, cosmetic, nice-to-have and removed P1-high Priority: major feature broken, no workaround labels Aug 6, 2026
@TomeHirata
TomeHirata requested review from TomeHirata and removed request for aravind-segu August 7, 2026 03:47
@TomeHirata

Copy link
Copy Markdown
Contributor

/review

@TomeHirata
TomeHirata requested a review from aravind-segu August 7, 2026 03:57
@omnigent-ci

omnigent-ci Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Polly AI Review

Blocking issues

1. The main per-conversation session_state write is now able to raise, and unlike the two paths you wrapped, nothing catches it.

apply_state_updates previously ended the session_ops path with set_session_state, which issues an UPDATE that matches nothing on a deleted row — a silent no-op. It now calls mutate_session_state, which by its new contract raises ConversationNotFoundError when the metadata row is gone (engine.py:559–562_mutate_metadata_json).

The PR deliberately absorbs that exception on the two special paths (_accumulate_session_usage, _record_root_cost_ask_approved), on the stated rationale that a session deleted mid-turn should not fail an in-flight streaming turn. But the ordinary per-conversation path — every non-cost-approval SET/INCREMENT/APPEND/DELETE — is unguarded. Its callers do not catch it:

  • engine.evaluate_compose_allow / _compose_deny (engine.py:397, 439)
  • _hold_native_ask_gate_impl (orchestration.py:1930)
  • _apply_pending_policy_ask_writes (helpers.py:6788) and the MCP retry path (orchestration.py:8245)
  • approval.py:163

So a session whose row is deleted mid-turn while any normal state update is applied now propagates ConversationNotFoundError up through policy evaluation — exactly the mid-turn-deletion regression the PR argues against, left live on the most common write path. Either the primitive shouldn't raise for the self-conversation write, or apply_state_updates' session_ops branch needs the same contextlib.suppress(ConversationNotFoundError) treatment (mirroring the state to the hot cache, as _record_root_cost_ask_approved does).

2. The new store methods call the managed-session makers with no query_name; on current main that is a required positional argument.

seed_labels_if_absent and _mutate_metadata_json open sessions as self._conv_session() and self._session_immediate() (no arg). On main, make_named_managed_session_maker returns a context factory whose signature is named_managed_session(query_name: str) and which rejects an empty/missing name — every other call site passes one (e.g. self._session_immediate("increment_session_usage") at sqlalchemy_store.py:1401). The diff's own unchanged context (the arg-less set_labels body and the removed arg-less increment_session_usage) shows the branch predates that requirement. As written these calls will raise on main after rebase, and they also violate the repo's mandatory "every session gets a stable semantic operation name" convention. Rebase and give each new session a query name ("seed_labels_if_absent", "mutate_session_state" / "increment_session_usage").

Security vulnerabilities

None. The label insert is fully parameterized (Core insert().values(rows) + on_conflict_do_nothing), no secret handling changes, no auth-boundary change. No lockfile pins or dependency extras were touched.

Non-blocking notes

  • seed_labels_if_absent's existence check is an unlocked SELECT and the docstring is candid that this only narrows (does not close) the orphan-row window against a concurrent delete. Fine as documented, but worth confirming an orphan SqlConversationLabel row for a since-deleted conversation is harmless downstream (no reader treats a labels-only, metadata-gone conversation as live).
  • _mutate_metadata_json re-imports json locally though it's already module-level in this file — harmless but redundant.
  • test_two_real_writers_race_on_one_metadata_row relies on a Barrier(2, timeout=0.5): correct serialization is proven by the barrier timing out. Under a loaded CI runner the 0.5s window is a plausible flake source; consider a more generous timeout since the barrier expiry is the expected (not error) path.

Summary

The core design is sound: pushing the read-merge-write and existence check inside one locked transaction is the right fix for both silent-lost-update races, the shared _mutate_metadata_json de-duplicates the usage/state loops correctly, and the hot-cache overlay reasoning (delete-vs-never-persisted told apart by this batch's ops) is right and well-tested. Two things block: the ordinary session_state write can now raise ConversationNotFoundError on a mid-turn deletion with no caller handling it — the same failure the PR explicitly guards against on its two special paths — and the new store methods omit the now-required query_name on their session makers, which won't run on current main. Resolve those two and this is a solid, well-covered change.


Automated review by Polly · workflow run

@TomeHirata

Copy link
Copy Markdown
Contributor

Superseded by #4320 — rebased both commits (this one + #3417) cleanly onto main as a standalone PR.

@TomeHirata TomeHirata closed this Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@andrewreid this PR was closed by a maintainer. If you think that was a mistake, reply here and ask them to reopen it. /reopen only undoes automated closes. See CONTRIBUTING.md.

@TomeHirata

Copy link
Copy Markdown
Contributor

Superseded by #4329 — rebased cleanly onto main with CI fixes (query-name lint, test helpers).

@TomeHirata TomeHirata closed this Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-demo PR needs a demo screenshot or recording P3-low Priority: minor issue, cosmetic, nice-to-have size/XL Pull request size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Label seeding and session-state writes race under concurrent updates — lost updates and orphan rows

6 participants