feat(state): indexed sessions.last_active for recents ordering + legacy trigram opt-out - #70429
feat(state): indexed sessions.last_active for recents ordering + legacy trigram opt-out#70429ildunari wants to merge 1 commit into
Conversation
Two related storage improvements to hermes_state.py:
1. sessions.last_active (schema v24): a denormalized, indexed column
(idx_sessions_last_active) that list_sessions_rich's recents view can
ORDER BY directly instead of computing "most recent activity per
compression chain" via a recursive CTE over every candidate row on
each call. The column is kept current by:
- a SQL trigger pair (LAST_ACTIVE_REPAIR_TRIGGER_SQL) that propagates
message timestamp updates and session started_at changes up through
compression-continuation chains,
- _touch_session_last_active (append_message / bulk insert) and
_refresh_session_last_active (session create/end/reopen, message
replace/clear, delete/prune/delete_empty, import) for the write
paths that need an authoritative recompute rather than a monotonic
touch.
A v24 migration backfills the column for every existing session on
next open. Read-only cross-profile DB handles (which never migrate)
detect a missing physical column via _has_sessions_last_active and
fall back to the original computed-subquery SELECT.
Fixed three destructive/import paths that mutated messages without
refreshing affected compression ancestors: clear_messages and
delete_empty_sessions (capturing ancestor ids before deletion,
matching the pattern already used in delete_session/delete_sessions/
prune_sessions), and import_sessions (which wires parent_session_id
only after all messages are already inserted, so the per-message
touch calls ran before there was any chain to climb — the chain is
now explicitly settled via _refresh_session_last_active once every
parent edge is final, which also gives an empty imported session a
real last_active instead of leaving it NULL).
Known, documented limitation (deliberately NOT solved in this PR —
see PR description for the full writeup and rejected alternatives):
a writer that mutates messages without going through
_touch_session_last_active/_refresh_session_last_active — an older
process version sharing this state.db mid-rolling-upgrade, a
different language binding, a raw SQL script — leaves last_active
stale (behind true activity) until the next helper-mediated write on
that session. This is a recents-ordering staleness only, never data
loss, and self-heals the moment normal traffic resumes. An earlier
version of this PR added an unconditional on-open reconciliation
pass; a second review round found it insufficient (misses delete-
drift and fresh-descendant/stale-ancestor shapes), unsafe for
read-only cross-profile handles (which never run it), unbounded on
large tables, and capable of moving a compression ancestor's
last_active BACKWARD — so it was removed rather than patched further.
See the PR description for the full tradeoff and a suggested home
(`hermes sessions optimize-storage`) if maintainers want an
operator-triggered repair pass.
2. sessions.disable_fts_trigram (config-backed, env override
HERMES_DISABLE_FTS_TRIGRAM): lets a legacy (pre-v23 inline-FTS)
install skip recreating its heavyweight trigram index, which — unlike
the v23 external-content trigram table — duplicates full message
content including large tool-call payloads. The flag only affects
legacy installs still awaiting `hermes sessions optimize-storage`;
v23-shape databases always keep the compact trigram index regardless
of the setting, and the explicit optimizer still backfills it for a
disabled legacy DB once migrated. Config surface/docs for this flag
are intentionally deferred, not part of this PR.
Testing done (in this worktree, /Users/Kosta/.hermes/hermes-agent/.venv):
- tests/test_hermes_state.py: 424 passed (includes 3 regression tests:
clear_messages ancestor refresh, import chain settle, and a
self-heals-on-next-write test proving the documented mixed-writer
contract — stale after a bypassing raw-SQL insert, corrected by the
next normal append_message call)
- tests/hermes_state/, tests/state/: all passed
- tests/gateway/test_session_list_allowed_sources.py,
tests/gateway/test_async_session_db.py,
tests/test_empty_session_hygiene.py,
tests/hermes_cli/test_resolve_last_session.py,
tests/hermes_cli/test_session_export_md.py,
tests/hermes_cli/test_session_browse.py,
tests/cli/test_cli_resume_command.py,
tests/hermes_state/test_session_archiving.py,
tests/hermes_state/test_resolve_resume_session_id.py: all passed
Related: #68394 proposes a different trigram-FTS configuration surface. This PR also adds the distinct sessions.last_active migration/index work, so the overlap needs a maintainer configuration-contract decision rather than a duplicate closure. |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the detailed write-path analysis. The indexed-recency premise remains live: current list_sessions_rich() still computes compression-chain activity with a recursive CTE at hermes_state.py:5122-5226.
Problems
- The PR predates
21c7ae8563, which split SessionDB intohermes_state_schema.py,hermes_state_search.py, andhermes_state_portability.py; current composition is athermes_state.py:70-72. The monolithic edits need a deliberate port, including current append/delete/import paths. - Proposed
hermes_state.py:318adds a user-facingHERMES_DISABLE_FTS_TRIGRAMoverride and direct YAML parsing. Current session-search settings usehermes_cli/config_defaults.py:2579-2609plus config-authoritative gateway bridging atgateway/run.py:1729-1736.
Suggested changes
- Port the
last_activemigration/index and write-path maintenance to the current mixin boundaries, retaining read-only cross-profile behavior athermes_state.py:1840-1858. - Re-scope the trigram option to the established config contract and reconcile it with the CJK-bigram initialization at
hermes_state_schema.py:726-728; #68394 is related configuration work.
Automated hermes-sweeper review.
| trigram from being recreated while a legacy database awaits explicit | ||
| ``optimize-storage`` migration. | ||
| """ | ||
| env_value = os.getenv("HERMES_DISABLE_FTS_TRIGRAM") |
There was a problem hiding this comment.
This makes a new non-secret HERMES_* variable a user-facing override. Please keep the setting config.yaml-authoritative through DEFAULT_CONFIG and the existing runtime bridge pattern, rather than accepting .env/process environment as a public configuration surface.
|
Thanks @ildunari — this was a well-built implementation (triggers + backfill + read-only fallback), but main has since solved the recents-ordering problem with a different architecture: last_active is now computed at query time via a correlated subquery over messages (COALESCE(last_activity_at, MAX(messages.timestamp), started_at) — hermes_state_common._sql_session_last_active), made index-only by the messages(session_id, id)/(session_id, timestamp) indexes (#76877/#76878). A denormalized column now has a semantic gap main's form doesn't: it wouldn't see the mid-turn last_activity_at heartbeat (#72424), so a rebased version would sort stale during active turns. Schema version 24 is also already taken, so the migration gate collides. The trigram opt-out fragment only helps pre-v23 installs and was a no-op on current shapes per your own PR notes. Closing as solved-differently-on-main — the perf goal you identified was real and is now covered. |
Part 1 — sessions.last_active (schema v24).
list_sessions_rich(order_by_last_active=True)currently computes "most recent activity, walking compression-continuation chains forward" via a recursive CTE over every WHERE-admitted row on every call. This adds a denormalizedsessions.last_active REALcolumn plusidx_sessions_last_active (last_active DESC, started_at DESC, id DESC), so the common unfiltered-recents case canORDER BY s.last_activedirectly against the index instead. The column is kept live by a SQL trigger pair (message timestamp updates and sessionstarted_atchanges propagate up through compression chains) plus two write-path helpers:_touch_session_last_active(monotonic advance — append_message, bulk insert) and_refresh_session_last_active(authoritative recompute — session create, end/reopen across a compression boundary, message replace/clear, single/bulk/pruned/empty-session delete, import), used wherever a naive "touch" could leave a stale ancestor last_active pointing at removed or not-yet-linked activity. A newcurrent_version < 24migration backfills every existing session on next open; read-only cross-profile DB handles (which never run migrations) detect the missing physical column via_has_sessions_last_activeand fall back to the original computed-subquery SELECT, so aggregating another profile's not-yet-migrated DB never raises "no such column".Also fixed three destructive/import paths that mutated messages without refreshing affected compression ancestors:
clear_messagesanddelete_empty_sessions(capturing ancestor ids before deletion, matching the pattern already used indelete_session/delete_sessions/prune_sessions), andimport_sessions— which wiresparent_session_idonly after all messages are already inserted, so the per-message touch calls ran before there was any chain to climb. The chain is now explicitly settled via_refresh_session_last_activeonce every parent edge is final, which also gives an empty imported session a reallast_activeinstead of leaving itNULL.Known limitation, left to maintainer discretion. Every write path this PR controls (the triggers plus the touch/refresh helpers above) keeps
last_activecorrect. But a writer that mutatesmessageswithout going through those helpers — an older hermes-agent version sharing thisstate.dbmid-rolling-upgrade, a different language binding, an ad hoc SQL script — will leave that session'slast_activestale (behind its true activity) until the next helper-mediated write on that session. This is strictly a recents-ordering staleness, never data loss: the message itself is intact and searchable, only its position in "most recently active" sorting can lag. It self-heals the moment normal traffic resumes on the session (proven bytest_stale_write_from_bypassing_writer_self_heals_on_next_write).We looked at closing this gap and rejected two approaches, in case a maintainer wants to revisit:
AFTER INSERT/AFTER DELETEtriggers onmessages, matching the existingAFTER UPDATE OF timestamptrigger. Rejected: SQLite firesAFTER INSERTbefore the Python-level_touch_session_last_activecall that follows the sameINSERTstatement inappend_message/_insert_message_rows, so a naive mirror trigger would run its recursive-CTE chain-walk body on every single message write — including the ones the Python helpers already handle correctly with an O(1) monotonic touch — defeating the point of adding an index in the first place._find_stale_last_active_session_ids, wired into_init_schema). A focused second review found it insufficient on its own terms: it missed delete-drift and fresh-descendant/stale-ancestor shapes (only compared each session's own message max against its own last_active, not the fuller set of ways a chain can drift); read-only cross-profile handles never call_init_schemaat all, so they'd trust stale data indefinitely regardless; the per-open scan is unbounded (~40ms measured at 25k messages, and the web-server paths open a writableSessionDBper request, making this a per-request cost); and — critically — it could move a compression ancestor'slast_activebackward relative to what a live process had already correctly advanced it to, which is exactly the invariant the monotonic-touch design is built to prevent. Patching all of that felt like the wrong shape for this PR, so it's removed rather than iterated further here.If maintainers want a stronger, operator-triggered guarantee here, the natural home looks like wiring a reconciliation pass into an existing maintenance entrypoint —
hermes sessions optimize-storagealready exists as a deliberate, foreground, disk-checked operation for exactly this class of "occasionally necessary, not safe to run silently on every request" repair. We're leaving that choice (and the shape of the fix) to the maintainers rather than including it in this PR.Part 2 — sessions.disable_fts_trigram. A legacy (pre-v23, inline-FTS) install can set this config key (or
HERMES_DISABLE_FTS_TRIGRAMenv var, as a process-transport fallback) to skip recreating its old heavyweight trigram index — which, unlike the v23 external-content trigram table, duplicates full message content including large tool-call payloads. The flag is a no-op on v23-shape databases (they always get the compact trigram index); the explicithermes sessions optimize-storagemigration still backfills the compact trigram table for a disabled legacy DB once it's opted in, refreshing_trigram_availableafter the demote step. Config surface/user-facing docs for this flag are intentionally deferred, not part of this PR.Testing done:
tests/test_hermes_state.py(424 passed, including regression tests for: legacy-version migration backfill parametrized over[17,19,23], two read-only-fallback tests, two compression-chain last_active-refresh tests covering end/reopen and delete/bulk-delete/prune,clear_messagesancestor refresh, import chain settle, and the bypassing-writer self-heal contract) andtests/hermes_state/test_fts_trigram_disable.py(7 passed) both pass in the PR worktree venv. Also ran the broader session-listing/gateway/web-server suites with no regressions.🤖 Generated with Claude Code