diff --git a/docs/sessions.md b/docs/sessions.md index c0b3a68..39b30d5 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -96,6 +96,8 @@ The indexes are built on the next store init (`CREATE INDEX IF NOT EXISTS`, a few seconds on 150 MB) and session search drops to tens of milliseconds. +**Counts and match positions.** The sidebar needs a display count and a preview per session, and a search page also needs *where* the term appears. Both used to walk the heap: the message rows are wide (`content`, `thinking`), and the session index has no `is_display` column. `idx_session_messages_counts` covers `(session_id, sequence_number, is_display)`, so the counts and the position numbering are index-only scans once the table is vacuumed (prod, 1.3k sessions: ~500 ms → ~100 ms). The match query numbers positions over that projection and reads `content` only for the rows the trigram index returns; numbering rows together with their content took ~1.7 s for a common term across the largest sessions, against ~100 ms this way. + **Ownership:** Legacy sessions (`user_id IS NULL`) are accessible only to admins. New sessions created by authenticated users carry `user_id`. The `list_all()` method respects the `is_admin` flag to filter appropriately. When `NAVI_AUTH_ENABLED=false`, every session is created with `user_id = 'anonymous'` and all access checks are bypassed. Querying `sessions.user_id = 'anonymous'` is a reliable way to identify sessions created in no-auth mode. diff --git a/navi/core/sessions/ddl.py b/navi/core/sessions/ddl.py index f3e1274..7c57845 100644 --- a/navi/core/sessions/ddl.py +++ b/navi/core/sessions/ddl.py @@ -72,6 +72,12 @@ CREATE INDEX IF NOT EXISTS idx_session_messages_session_seq ON session_messages(session_id, sequence_number); CREATE INDEX IF NOT EXISTS idx_session_messages_context ON session_messages(session_id, is_context, sequence_number); +-- The sidebar's counts and the search's display indices need only these three +-- columns, but the session index above lacks `is_display`, so every row cost a +-- heap fetch (the heap is wide: content and thinking). INCLUDE makes those +-- queries index-only once the table has been vacuumed. +CREATE INDEX IF NOT EXISTS idx_session_messages_counts + ON session_messages (session_id, sequence_number) INCLUDE (is_display); """ _SESSION_IMAGES_DDL = """ diff --git a/navi/core/sessions/rows.py b/navi/core/sessions/rows.py index 45e2670..70aeb45 100644 --- a/navi/core/sessions/rows.py +++ b/navi/core/sessions/rows.py @@ -171,6 +171,12 @@ list: display rows only, in ``sequence_number`` order, above the session's archive threshold. The preview comes from the first hit only, so nothing but one content per session crosses the wire. + + The positions are numbered over a *projection* — ``session_id``, + ``sequence_number``, ``is_display`` — that ``idx_session_messages_counts`` + covers, so the window runs as an index-only scan. Content is then read for + the hits the trigram index returns, not for every display row of the + session: a common term in big sessions cost ~1.7 s on prod the other way. """ if not session_ids or not search: return {} @@ -178,7 +184,7 @@ """ WITH hot AS ( SELECT m.session_id, - m.content, + m.sequence_number, row_number() OVER ( PARTITION BY m.session_id ORDER BY m.sequence_number ) - 1 AS idx @@ -188,17 +194,27 @@ AND m.is_display AND m.sequence_number >= coalesce(s.archive_threshold, 0) ), matched AS ( - SELECT * FROM hot WHERE content ILIKE $2 + SELECT hot.session_id, hot.idx, hits.content + FROM hot + JOIN ( + SELECT m.session_id, m.sequence_number, m.content + FROM session_messages m + WHERE m.session_id = ANY($1) + AND m.is_display + AND m.content ILIKE $2 + ) hits + ON hits.session_id = hot.session_id + AND hits.sequence_number = hot.sequence_number ) - SELECT agg.session_id, - agg.indices, - first.content AS first_content - FROM (SELECT session_id, array_agg(idx ORDER BY idx) AS indices - FROM matched - GROUP BY session_id) agg - JOIN matched first - ON first.session_id = agg.session_id - AND first.idx = agg.indices[1] + SELECT session_id, + array_agg(idx ORDER BY idx) AS indices, + (SELECT content + FROM matched x + WHERE x.session_id = matched.session_id + ORDER BY idx + LIMIT 1) AS first_content + FROM matched + GROUP BY session_id """, session_ids, f"%{search}%", diff --git a/tests/unit/core/test_pg_session_store.py b/tests/unit/core/test_pg_session_store.py index 374651d..c8808de 100644 --- a/tests/unit/core/test_pg_session_store.py +++ b/tests/unit/core/test_pg_session_store.py @@ -740,6 +740,18 @@ @pytest.mark.asyncio +async def test_the_counts_index_covers_is_display(): + """`(session_id, sequence_number)` alone forces a heap fetch per counted row.""" + from navi.core.pg_session_store import _ensure_normalized_tables + + conn = FakeConnection() + await _ensure_normalized_tables(conn) + + ddl = next(c[1] for c in conn.calls if "idx_session_messages_counts" in c[1]) + assert "INCLUDE (is_display)" in ddl + + +@pytest.mark.asyncio async def test_a_broken_index_does_not_break_schema_init(): """A row with unparsable session_metadata would otherwise fail every startup.""" from navi.core.pg_session_store import _ensure_normalized_tables diff --git a/tests/unit/core/test_session_list_summaries.py b/tests/unit/core/test_session_list_summaries.py index 217ebf9..e56c734 100644 --- a/tests/unit/core/test_session_list_summaries.py +++ b/tests/unit/core/test_session_list_summaries.py @@ -154,6 +154,26 @@ @pytest.mark.asyncio +async def test_match_positions_are_numbered_without_reading_content(): + """Numbering every display row with its content cost ~1.7 s on prod. + + The window must run over the projection the counts index covers; content is + read only for the hits the trigram filter returns. + """ + conn = FakeConnection() + conn.enqueue([]) + store = PgSessionStore(FakePool(conn)) + store._initialized = True + + await store.search_matches(["s1"], "needle") + + _, sql, _ = conn.calls[0] + projection = sql[sql.index("SELECT m.session_id") : sql.index("row_number()")] + assert "content" not in projection + assert "m.content ILIKE $2" in sql + + +@pytest.mark.asyncio async def test_search_matches_skips_the_query_when_there_is_nothing_to_do(): conn = FakeConnection() store = PgSessionStore(FakePool(conn))