mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
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:
@@ -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`. */
|
||||
|
||||
Reference in New Issue
Block a user