Skip to content

perf(gateway): single-row routing UPSERT fast path for metadata-only saves - #64169

Closed
Soju06 wants to merge 1 commit into
NousResearch:mainfrom
Soju06:upstream-pr/persist-trim
Closed

perf(gateway): single-row routing UPSERT fast path for metadata-only saves#64169
Soju06 wants to merge 1 commit into
NousResearch:mainfrom
Soju06:upstream-pr/persist-trim

Conversation

@Soju06

@Soju06 Soju06 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Problem

On a production deployment with ~1,100 gateway routing keys, per-turn gateway persistence (session resolve + post-turn save) measured ~175ms p50. Profiling attributed ~50ms of that to the routing-index save: every SessionEntry re-serialized, a full DELETE+INSERT of every gateway_routing row in state.db, and a multi-MB sessions.json dump+fsync — and the steady-state turn pays it twice (once in get_or_create_session's healthy-path updated_at bump, once in update_session), even though both writes only change updated_at/last_prompt_tokens on a single entry.

Change

Add SessionStore._save_entry(session_key): persist one routing entry via the existing HermesDB.save_gateway_routing_entry UPSERT instead of rewriting the whole index. Only the two metadata-only call sites use it:

  • get_or_create_session's healthy path (existing entry, no reset/recover/heal — just the updated_at bump)
  • update_session (updated_at / last_prompt_tokens)

Structural transitions — create, recover, reset, session switch, prune, and compression-tip heals (anything that changes the key → session_id mapping) — keep the full-rewrite path, which also refreshes the legacy sessions.json mirror. Between structural saves the mirror can lag in metadata only; state.db is the primary durable store for routing, so restart rebinding is unaffected.

update_session also now runs its SQLite write and peer-record update outside _lock, so the commit never blocks concurrent routing lookups.

Correctness notes

  • Generation guard vs concurrent full rewrites: the entry is serialized under _lock together with the current routing generation. Under _save_lock, the UPSERT is skipped when a full snapshot taken after our serialize point has already persisted — that snapshot necessarily contains a same-or-newer copy of the key, so writing ours would regress it. A full snapshot older than our serialize point that lands after us can only regress that key's metadata by one racing turn (the next turn rewrites it), never the session_id: session_id changes always carry a newer generation and win via the existing guard in _persist_routing_data.
  • Torn peer rows: update_session snapshots session_id/origin/display_name while still holding _lock, so a concurrent reset that rewrites the entry between lock release and the peer record cannot record a mix of old and new fields.
  • Heals force the full path: _heal_compression_tip_locked's return value now gates the fast path — a heal rewrites entry.session_id and must reach the sessions.json mirror too.
  • Fallbacks: no DB, or a failed UPSERT, falls back to the full rewrite, so DB-less installs keep sessions.json — their primary store — durable every turn.

Tests

New tests/gateway/test_routing_save_fast_path.py (13 tests): changed values always land in state.db; restart rebinding works when the sessions.json mirror lagged fast-path writes (or was deleted); compression heals and force_new transitions still rewrite the mirror; no-DB and failed-UPSERT fallbacks; peer fields snapshotted under _lock; generation-guard skip/proceed ordering, including restart rebinding after a skipped idempotent write.

python -m pytest tests/gateway: 9,124 tests, no regressions vs a clean checkout of main in the same environment (the same set of environment-dependent failures — Telegram/PTB and Feishu SDK version drift, path-completion fixtures — fails identically before and after this change).

Measured impact

Production deployment of this change at ~1,100 routing keys: metadata-only routing saves dropped from ~50ms (full index rewrite + sessions.json dump+fsync, twice per turn) to <1ms per single-row UPSERT. Structural transitions are unchanged.

🤖 Generated with Claude Code

@alt-glitch alt-glitch added type/perf Performance improvement or optimization comp/gateway Gateway runner, session dispatch, delivery sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state P3 Low — cosmetic, nice to have labels Jul 14, 2026
@Soju06
Soju06 force-pushed the upstream-pr/persist-trim branch 2 times, most recently from e7c9829 to 6ad2433 Compare July 14, 2026 05:39
@Soju06

Soju06 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

The failing Python tests slices here are test_model_validation.py::TestProbeApiModelsUserAgent — broken on main itself since b8eb89f (the tests mock urllib.request.urlopen, but the probe goes through open_credentialed_url's OpenerDirector). Test-only fix: #64200. Unrelated to this PR's diff.

@Soju06
Soju06 force-pushed the upstream-pr/persist-trim branch 2 times, most recently from 7cdd1c7 to bb7e695 Compare July 14, 2026 14:43

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for isolating a real per-turn routing persistence cost. Current main still serializes the full routing index on the healthy lookup path (gateway/session.py:2021-2022) and in update_session (gateway/session.py:2051-2065).

Problems

  • The new fast-path guard can let an older metadata snapshot overwrite a newer one. _save_entry reads _routing_generation but does not advance it, while the guard only skips when _persisted_routing_generation > snap_gen (PR gateway/session.py:1353). Two concurrent fast saves can therefore share snap_gen: after the newer UPSERT completes, the delayed older UPSERT still passes the guard and replaces entry_json. save_gateway_routing_entry is an unconditional conflict update (hermes_state.py:1909-1914). This regresses the ordering contract introduced by b196ce80c and currently implemented in gateway/session.py:1241-1275.

Suggested changes

  • Give metadata-only writes a durable ordering mechanism (for example, per-entry revisions), and add a deterministic reverse-completion test for two same-key fast saves plus the full-save interaction.

Automated hermes-sweeper review.

Comment thread gateway/session.py
save_lock = threading.Lock()
self._save_lock = save_lock
try:
with save_lock:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

snap_gen is only read, never advanced for fast-path mutations. Two same-key fast saves can therefore share it; if the newer UPSERT wins first, a delayed older snapshot still passes this strict > check and overwrites the newer entry_json. Please add an ordering mechanism for per-entry writes and a forced reverse-completion regression test.

@Soju06
Soju06 force-pushed the upstream-pr/persist-trim branch from bb7e695 to ed481e6 Compare July 16, 2026 02:19
@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 16, 2026
…saves

The steady-state turn only bumps updated_at/last_prompt_tokens on one
routing entry, but persisted it through the full index rewrite twice
per turn (get_or_create_session's healthy-path bump + update_session):
every entry re-serialized, DELETE+INSERT of every gateway_routing row,
and a multi-MB sessions.json dump+fsync — ~50ms p50 at ~1,100 routing
keys in production, out of ~175ms total per-turn gateway persistence.

Metadata-only saves now UPSERT the single row via the existing
HermesDB.save_gateway_routing_entry (<1ms). Structural transitions
(create/recover/reset/switch/prune, compression-tip heals) keep the
full rewrite, which also refreshes the legacy sessions.json mirror.

Correctness: each fast save allocates a per-entry revision from the
routing generation counter under _lock, so fast and full snapshots are
totally ordered by number. Under _save_lock the UPSERT is skipped when
a newer full snapshot or a newer fast save of the same key has already
persisted, and a delayed full rewrite folds in fast records serialized
after its snapshot before writing — an older snapshot can never
overwrite a newer one, in either direction. update_session snapshots
peer fields under _lock so a concurrent reset cannot record a torn
peer row; no DB or a failed UPSERT falls back to the full rewrite so
DB-less installs keep sessions.json durable every turn.
@Soju06
Soju06 force-pushed the upstream-pr/persist-trim branch from ed481e6 to b6087a3 Compare July 16, 2026 15:08
@Soju06

Soju06 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Good catch — the fast path could indeed let a delayed older metadata UPSERT land over a newer one, since two same-key fast saves shared snap_gen and the guard only compared against full-snapshot generations. Fixed by giving fast saves a durable per-entry ordering: each _save_entry now allocates a revision from the same routing generation counter under the session lock at serialize time, so fast and full snapshots are totally ordered by number. Under the save lock the UPSERT is skipped when a newer full snapshot or a newer fast save of the same key has already persisted, and the reverse interaction is handled in _persist_routing_data — a delayed full rewrite folds in fast records serialized after its snapshot before writing (state.db and the sessions.json mirror), so an older snapshot can never overwrite a newer one in either direction. The fast path stays allocation-light: one counter increment plus a dict slot that reuses the already-serialized entry_json. Added the requested deterministic tests (a lock-gate wrapper parks a writer between its serialize point and its durable write): reverse-order completion of two same-key fast saves keeps the newer entry_json; a delayed older full rewrite preserves a later fast save; a delayed fast save skips after a newer full rewrite. The first two fail on the previous implementation. Rebased onto current main.

@Soju06

Soju06 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@teknium1 Gentle ping — all points from the review here have been addressed (summary in the comment above), the branch is rebased on current main, and CI is green. Ready for another look whenever convenient.

@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Thanks @Soju06 — excellent work on the risky part of this PR class: the shared-counter revision ordering and the fold-in logic both held up under independent interleaving analysis, and the field-completeness audit confirmed no data loss (same to_dict serializer both paths). Verified (16/16 new tests, mutation-checked, seam audit clean) and salvaged into #76916 with your authorship preserved via cherry-pick, plus one small follow-up extracting a single allocator for the shared counter so the two bump sites can't drift. Closing in favor of the salvage.

kshitijk4poor added a commit that referenced this pull request Aug 2, 2026
Review follow-up on the #64169 salvage: _save_entry duplicated
_snapshot_routing_locked's counter-bump line verbatim. The stale-write
protection is a total order over ONE counter — extract
_next_routing_generation_locked() so the two allocation sites can't
drift apart silently.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
Review follow-up on the NousResearch#64169 salvage: _save_entry duplicated
_snapshot_routing_locked's counter-bump line verbatim. The stale-write
protection is a total order over ONE counter — extract
_next_routing_generation_locked() so the two allocation sites can't
drift apart silently.
prmartinow pushed a commit to prmartinow/hermes-agent that referenced this pull request Aug 26, 2026
Review follow-up on the NousResearch#64169 salvage: _save_entry duplicated
_snapshot_routing_locked's counter-bump line verbatim. The stale-write
protection is a total order over ONE counter — extract
_next_routing_generation_locked() so the two allocation sites can't
drift apart silently.
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
Review follow-up on the NousResearch#64169 salvage: _save_entry duplicated
_snapshot_routing_locked's counter-bump line verbatim. The stale-write
protection is a total order over ONE counter — extract
_next_routing_generation_locked() so the two allocation sites can't
drift apart silently.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/perf Performance improvement or optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants