fix(session-search): Project search works for projects with hundreds of worktrees (#31603)

* fix(session-search): Project search works for projects with hundreds of worktrees

A Project-scope search showed "The search index is not ready yet" on a fully
indexed computer when the project had ~500 or more worktree paths outside a
shared workspace folder. Each scope path added an OR arm to the session filter,
and past ~495 arms SQLite refuses the statement ("Expression tree is too large
(maximum depth 1000)"); the desktop then reported the thrown search as not-ready.

The scope ranges now travel as one JSON parameter read through json_each, so the
statement has the same shape for any number of paths and each path is still a
seek on sessions_cwd_key. A failed search is also logged before it is reported
as not-ready.

* refactor(session-search): one scope subquery with one parameter; keep lone-surrogate scopes matching

One json_each join with the exact-or-range ON plans as a MULTI-INDEX OR seek,
so the UNION ALL and second bound copy go. Scope keys are made well-formed
before JSON, since a bound key stores a lone surrogate as U+FFFD and JSON
would decode it to other bytes. The plan test now also catches a scan of the
aliased inner table.
This commit is contained in:
Jinwoo Hong
2026-10-21 14:56:38 -04:00
committed by GitHub
parent 2b1da76d70
commit 5d713eb41d
3 changed files with 40 additions and 24 deletions
@@ -27,7 +27,9 @@ export function createChildSessionSearchService(
search: async (request, hostScope) => {
try {
return await calls.search(request, hostScope)
} catch {
} catch (error) {
// The child's own search errors land here too; without this they read only as "not ready".
console.warn('[ai-vault-search] search failed', error)
return { kind: 'unavailable', reason: 'not-ready' }
}
},
@@ -59,6 +59,7 @@ describe('a cwd scope is the sidebar key, or anything below it', () => {
['/work/app-other', '/work/app', false],
['/work/a_b/src', '/work/a_b', true],
['/work/axb/src', '/work/a_b', false],
['/work/lone\ud800/src', '/work/lone\ud800', true],
// Roots: `/` is the one key that is already a separator, which is where a
// range bound is easiest to get wrong. A Windows key is not under POSIX `/`.
['/', '/', true],
@@ -143,5 +144,13 @@ it('plans a cwd scope as a seek on sessions_cwd_key, never a scan', async () =>
expect(plan.join(' | ')).toContain('sessions_cwd_key')
expect(plan.some((detail) => detail.startsWith('SEARCH'))).toBe(true)
expect(plan.some((detail) => detail.startsWith('SCAN sessions'))).toBe(false)
expect(plan.some((detail) => /^SCAN (sessions|scoped)\b/.test(detail))).toBe(false)
})
it('matches under a thousand-plus scopes, past where an OR chain exceeds SQLite expression depth', async () => {
const db = await openIndex()
addSession(db, 1, '/lanes/lane-1199/src')
addSession(db, 2, '/elsewhere')
const scopePaths = Array.from({ length: 1_200 }, (_, index) => `/lanes/lane-${index}`)
expect(selected(db, { scopePaths })).toEqual([1])
})
@@ -49,39 +49,44 @@ export function sessionRowFilter(
filter.values.push(filters.since)
}
if (filters.scopePaths && filters.scopePaths.length > 0) {
// Several scopes mean any of them; every other narrowing is ANDed on.
const present = filters.scopePaths
.map((scope) => scopeCondition(filter, scope))
.filter((condition) => condition !== null)
// Every scope unkeyable still means a scope, so it narrows to nothing;
// pushing no condition would widen the search to every session instead.
filter.conditions.push(present.length > 0 ? `(${present.join(' OR ')})` : '0 = 1')
// Any scope matches; one that cannot be keyed matches nothing, and all-unkeyable must not widen to every session.
const keys = filters.scopePaths.map(cwdKey).filter((key) => key !== null)
if (keys.length === 0) {
filter.conditions.push('0 = 1')
} else {
filter.conditions.push(SCOPE_CONDITION)
filter.values.push(JSON.stringify(keys.map(scopeRange)))
}
}
return filter
}
/** A scope the caller could not key is a scope nothing is inside of. */
function scopeCondition(filter: SessionRowFilter, scope: string): string | null {
const key = cwdKey(scope)
return key === null ? null : insideCondition(filter, key)
}
/**
* `key` itself, or anything below it. Why a half-open range and not
* `substr(key, 1, length(?)) = ?`: only `>=`/`<` can seek `sessions_cwd_key`;
* the substr form scans it. The bound is the child prefix with its last byte
* incremented, so it stops at the end of that prefix and nowhere else. The two
* arms cannot merge: one range over the bare key would also swallow a sibling
* like `/work/app-other`. No wildcards, so `%`/`_` in a folder name are literal.
* `key` itself, or anything below it. One JSON parameter, not an OR per scope:
* ~500 OR arms exceed SQLite's expression depth of 1000 and the search throws.
*
* Why a half-open range and not `substr(key, 1, length(?)) = ?`: only `>=`/`<`
* can seek `sessions_cwd_key`; the substr form scans it. The bound is the child
* prefix with its last byte incremented, so it stops at the end of that prefix
* and nowhere else. The two arms cannot merge: one range over the bare key would
* also swallow a sibling like `/work/app-other`. No wildcards, so `%`/`_` in a
* folder name are literal.
*
* The filesystem root is the one key that already ends in a separator, and
* appending a second one would bound the range at `//`, which sorts below every
* real child; `cwdKey` keeps it as `/` for exactly this reason.
*/
function insideCondition(filter: SessionRowFilter, key: string): string {
const SCOPE_CONDITION = `id IN (
SELECT scoped.id FROM json_each(?) AS scope JOIN sessions AS scoped
ON scoped.${CWD} = scope.value ->> 0
OR (scoped.${CWD} >= scope.value ->> 1 AND scoped.${CWD} < scope.value ->> 2)
)`
function scopeRange(scopeKey: string): [string, string, string] {
// A bound key stores a lone surrogate as U+FFFD; JSON would decode it to other bytes.
const key = scopeKey.toWellFormed()
const children = key.endsWith('/') ? key : `${key}/`
filter.values.push(key, children, nextAfterPrefix(children))
return `(${CWD} = ? OR (${CWD} >= ? AND ${CWD} < ?))`
return [key, children, nextAfterPrefix(children)]
}
/** The first string that sorts after every string starting with `prefix`. */