Repository navigation
perf(store): the local SQLite document store answers indexed lookups from its indexes - #38
Merged
Merged
Conversation
…from its indexes A filter that pins the leading fields of one of a collection's indexes by equality now reads only the rows that index finds, instead of parsing every document. find_one, query, count and the update, replace and delete paths all read through it, so VersionedEntityStore.next_version() and put_bundled stop scanning. The filter and sort still run in Python on those rows, in rowid order, so results are unchanged. Indexes are read from sqlite_master, so no migration: existing stores use the indexes they already have. The per-table index cache drops when PRAGMA schema_version changes, so a long-lived reader starts searching an index another process creates. A write that waits out the busy timeout now raises DatabaseLockedError naming the database file, which the CLI prints as one line. The first open's switch to WAL raises it too, so @Local routing no longer turns a temporary lock into "move it aside". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ensure_index and the pushdown query now build json_extract(doc, '$.<field>') through _field_expr, so the index and the query that must match it can't drift apart. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CREATE INDEX bumps PRAGMA schema_version on the connection that runs it, so the version check already covers an index this store adds; ensure_index no longer clears the cache by hand. The reader test now runs for the same store and another connection. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The CLI listed DatabaseLockedError among its one-line user errors, which made it import the SQLite backend. A lock timeout now prints like any other unexpected error, as a traceback that ends in the same message naming the database file. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Collaborator
Author
|
@greptileai review On the lock timeout reaching CLI users as a traceback: that's a deliberate trade-off. Listing The timeout still raises
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
LocalSqliteDocumentStorenow reads only the rows an index finds when a filter pins the leading fields of one of the collection's indexes by equality. The query isWHERE json_extract(doc, '$.<field>') = json_extract(?, '$') … ORDER BY rowid, which is the index's own expression, so SQLite searches the index.find_one,query,count,update,update_one_and_get,replaceanddeleteall read through it, soVersionedEntityStore.next_version()andput_bundledno longer scan.evaluation.matches/sort_docsstill run in Python on those rows, in rowid order as before, so results and their order don't change.strorintvalue on top-level fields. The value is bound as JSON, so it decodes the same way as the stored field.id+typesearches(id, version), nottype.None, floats, lists,In, dotted paths (which can step through arrays), and fields with no index.sqlite_master, so a process that reads before it ensures any index still uses them. SQLite searches(id, version)byidalone, so no new index is added and existing stores open unchanged.PRAGMA schema_versionchanges, so a long-lived reader starts searching a new index without a restart.DatabaseLockedError: <path> is locked: another process has held its write lock for over 5s, which names the database file.sqlite3.OperationalError: database is locked, with no path.@localrouting no longer reports a temporary lock as an unreadable store.Why
Every read parsed the whole collection, and the single per-user local store only grows. On
main, afind_oneby id takes about 70 ms at 10,000 documents and over a second at 100,000. A versioned put runsnext_version(), so writes pay the same scan inside their write lock, andput_bundledslows as the store grows.Testing
Where this was tested. Every row ran against this branch, on macOS (Apple M4 Pro, SQLite 3.50.4), with
mainas the control.main: 5752 passed). 15 of the store tests fail againstmain'ssrc, including all 12 query-plan casesSEARCH … USING INDEXon this branch,SCANonmainmain; scans are unchangedput_bundledof 1,000 filesmain; every write accounted for;integrity_checkokkill -9mid-writemainmainmainreads it backDatabaseLockedErrornaming the file after about 5.4 s;mainraises a bareOperationalErrorQuery plans.
EXPLAIN QUERY PLANruns on the statements each call actually executes, captured with a trace callback.SEARCH … USING INDEXfor 12 shapes:find_oneby id and by (id, version),query,count,next_version,update,update_one_and_get,replace,delete, a unique index on a tie, a secondary index, and the longest pinned prefix.SCANfor the filters that stay in Python.docs_artifacts_id_version_unique, andquery(type)usesdocs_artifacts_type_index. Onmainevery call scans.Differential test. A seeded run applies 1,500 random reads, updates, upserts, replaces and deletes to 300 random documents. It checks outcomes and order against the same store with no index.
main, its "pushdown was exercised" check fails.Benchmark. Median ms of 50 calls after 3 warm-up calls, on artifact-shaped documents (1.5 KB, 4 versions per id) with the indexes real stores create. Every answer was checked.
main→ branchfind_one(id)find_one(id, version)query(id)count(id)next_versionupdate(id, version)count(), no filter (scan)find_one(id)took 0.33 ms andupdate0.22 ms.mainand this branch on the unfiltered scan overlap at every size.Writer contention. 8 processes ran over 10,000 documents of 14 KB. The mix was 30% versioned puts onto 20 shared ids, 20% compare-and-set on 4 counters, 20% updates on 100 hot documents and 30% reads. Each process logged every successful operation, and raw
sqlite3checked the store afterwards.main, 30 s: 696 operations, at 23 ops/s. Put p50 was 283–597 ms and update p50 182–223 ms.integrity_checkis ok.kill -9. A writer did continuous versioned puts and updates with the WAL active (2–4 MB) and was killed at a random point 0.2–1.5 s in. After every round, on this branch (20 rounds) and onmain(5 rounds):integrity_checkwas ok;Existing store. A store written with
main's code through the real APIs:2.0; the index setup of the task-instance, env-artifact, run, conversation and ledger stores; compare-and-set; and an edge collection with bool, float, null, absent and array values in indexed fields.main.sqlite_master, the schema version and all 98 pre-existing rows stayed byte-identical after this branch read and wrote.main: it then read the store back with the same answers.Lock timeout. Another process held the write lock, first under
BEGIN IMMEDIATEwith an uncommitted insert, then underBEGIN EXCLUSIVE.ensure_indexand delete each raisedDatabaseLockedErrorafter 5.37–5.42 s, with a message naming the file:…/store.db is locked: another process has held its write lock for over 5s.main: they raisedsqlite3.OperationalError: database is locked.Not in this PR
Eq("red")matched 2 documents onmainand 1 here.VersionedEntityStore.put's retry budget. Its 5 retries can run out when several processes put the same few ids hundreds of times a second, which this branch makes reachable. It raisesDuplicateKeyErrorand writes nothing: 1 of 6,863 puts in the 8-process run.next_version()andget(id)now read only that id's rows, but they still load and sort all its versions in Python. With 300–390 versions of 14 KB each, that is what puts cost in the contention run.count(),In, and filters on unindexed fields.🤖 Generated with Claude Code
The PR appears safe to merge; no new actionable issue was found.
Summary
This PR makes indexed equality lookups in the local SQLite store read candidate rows instead of whole collections, while keeping Python matching and sorting. It also gives write-lock timeouts a path-specific error.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart LR F[Document filter] --> I{Index prefix matches?} I -- Yes --> R[Read matching rows] I -- No --> A[Read all rows] R --> P[Check filter in Python] A --> PReviews (4) · Last reviewed commit: "revert(cli): keep the SQLite backend's l..."