Skip to content

perf(otel-sqlite): add stored session_id column to eliminate full-table scans#314900

Open
makroumi wants to merge 3 commits intomicrosoft:mainfrom
makroumi:perf/otel-sqlite-session-id-index
Open

perf(otel-sqlite): add stored session_id column to eliminate full-table scans#314900
makroumi wants to merge 3 commits intomicrosoft:mainfrom
makroumi:perf/otel-sqlite-session-id-index

Conversation

@makroumi
Copy link
Copy Markdown

@makroumi makroumi commented May 7, 2026

Problem

Every session-scoped query in OTelSqliteStore performed a full table scan on spans.

Root causes confirmed with EXPLAIN QUERY PLAN:

  1. COALESCE(conversation_id, chat_session_id) in WHERE and GROUP BY; SQLite cannot use an index on a function-wrapped column. Both idx_spans_conversation and idx_spans_chat_session were defined but unreachable for these queries.

  2. OR across two columns (conversation_id = ? OR chat_session_id = ?): the query planner cannot satisfy this with a single index seek.

Affected: getSpansByConversationId, getTraceIds, the sessions view (used by getSessions and getSessionsSince), and both cleanup subqueries in _cleanupOnStartup.

Solution

Add session_id TEXT GENERATED ALWAYS AS (COALESCE(conversation_id, chat_session_id)) STORED to spans. SQLite computes and persists this at write time. No changes to INSERT paths or call sites.

Add idx_spans_session ON spans(session_id, start_time_ms) — composite index covering the equality lookup and the ORDER BY start_time_ms used in getSpansByConversationId.

Changes

Location Before After
getSpansByConversationId WHERE conversation_id = ? OR chat_session_id = ? WHERE session_id = ?
getTraceIds WHERE conversation_id = ? OR chat_session_id = ? WHERE session_id = ?
sessions view GROUP BY COALESCE(conversation_id, chat_session_id) GROUP BY session_id
_cleanupOnStartup cutoff GROUP BY COALESCE(...) GROUP BY session_id
_cleanupOnStartup DELETE NOT IN subquery NOT EXISTS — NULL-safe by structure
SCHEMA_VERSION 1 2

Migration

v1 → v2 uses the standard SQLite table rebuild pattern: rename existing table → recreate with generated column → INSERT INTO ... SELECT with explicit 24-column list (session_id excluded, it is GENERATED ALWAYS) → drop old table → recreate indexes → drop and recreate sessions view. Required because SQLite does not support ALTER TABLE ADD COLUMN for generated columns.

Verification

EXPLAIN QUERY PLAN
SELECT * FROM spans WHERE session_id = 'x';
→ SEARCH spans USING INDEX idx_spans_session (session_id=?)

EXPLAIN QUERY PLAN
SELECT session_id, MIN(start_time_ms) FROM spans
WHERE session_id IS NOT NULL GROUP BY session_id;
→ SEARCH spans USING INDEX idx_spans_session (session_id=?)

Migration verified: row count preserved, session_id correctly resolves to conversation_id when present, chat_session_id as fallback, null when both are absent. sessions view aggregates correct post-migration.

…le scans

SCHEMA_VERSION 1 -> 2.

Problem:
Every session-scoped query and cleanup operation performed a full table scan on spans. COALESCE(conversation_id, chat_session_id) in WHERE and GROUP BY clauses prevents index usage; SQLite cannot use an index on a function-wrapped column. OR across two indexed columns (conversation_id,
chat_session_id) forces the query planner to evaluate both indexes and merge results, also degrading to a scan at scale.

Changes:
- Add session_id TEXT GENERATED ALWAYS AS (COALESCE(conversation_id, chat_session_id)) STORED to spans. SQLite computes and stores this at write time, zero application-layer changes to INSERT paths.
- Add idx_spans_session ON spans(session_id, start_time_ms) as a composite index covering both the session lookup and the ORDER BY start_time_ms present in getSpansByConversationId.
- Rewrite getSpansByConversationId: WHERE session_id = ? (index seek, was full scan via OR).
- Rewrite getTraceIds: WHERE session_id = ? (index seek, was full scan via OR).
- Rewrite sessions view: GROUP BY session_id, WHERE session_id IS NOT NULL (index seek, was COALESCE in GROUP BY forcing full scan).
- Rewrite _cleanupOnStartup: GROUP BY session_id in both the cutoff subquery and the DELETE subquery (index seek, was COALESCE full scan).
- Replace NOT IN subquery with NOT EXISTS in _cleanupOnStartup. Correctness of NOT IN depended on an implicit NULL guard inside the subquery. NOT EXISTS is NULL-safe by structure.
- v1 to v2 migration: ALTER TABLE RENAME, CREATE TABLE with generated column, INSERT SELECT with explicit 24-column list excluding session_id, DROP old table, recreate indexes, DROP and recreate sessions view. SQLite does not support ADD COLUMN for generated columns via ALTER TABLE.

Verified:
- EXPLAIN QUERY PLAN confirms SEARCH spans USING INDEX idx_spans_session for all session-scoped queries (previously SCAN spans).
- Migration preserves row count and correctly computes session_id for conversation_id rows, chat_session_id fallback rows, and null rows.
- sessions view returns correct aggregates post-migration.
- Zero changes to public API surface or INSERT parameter lists.
Copilot AI review requested due to automatic review settings May 7, 2026 02:31
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR improves performance of the Copilot extension’s OTelSqliteStore SQLite queries by introducing a persisted session_id generated column and a composite index, allowing session-scoped lookups and aggregations to use indexed seeks instead of full-table scans.

Changes:

  • Bump schema to v2 and add session_id TEXT GENERATED ALWAYS AS (COALESCE(conversation_id, chat_session_id)) STORED plus idx_spans_session(session_id, start_time_ms).
  • Rewrite session-scoped queries (getSpansByConversationId, getTraceIds) and the sessions view to use session_id.
  • Update startup cleanup logic to group by session_id and switch the retention delete to NOT EXISTS.

Comment thread extensions/copilot/src/platform/otel/node/sqlite/otelSqliteStore.ts Outdated
Comment thread extensions/copilot/src/platform/otel/node/sqlite/otelSqliteStore.ts Outdated
Comment thread extensions/copilot/src/platform/otel/node/sqlite/otelSqliteStore.ts Outdated
@makroumi
Copy link
Copy Markdown
Author

makroumi commented May 7, 2026

@microsoft-github-policy-service agree

makroumi added 2 commits May 6, 2026 22:36
- Use MAX(version) and single-row enforcement to prevent schema_version
  accumulation (INSERT OR REPLACE with different PK values creates new rows)
- Use CREATE new / DROP old / RENAME pattern to preserve FK integrity
  (ALTER TABLE RENAME breaks FKs pointing at the old table name)
- Wrap migrations in BEGIN IMMEDIATE / COMMIT / ROLLBACK for atomicity
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants