Skip to content

sqlitedb: make id, isa and regexp searches fast (doc_data search indexes, restricted AND terms) - #229

Merged
stevevanhooser merged 4 commits into
mainfrom
claude/did-base-id-index
Oct 24, 2026
Merged

stevevanhooser merged 4 commits into
mainfrom
claude/did-base-id-index

Conversation

@stevevanhooser

@stevevanhooser stevevanhooser commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

What was slow

NDI's most common searches pair an id or class lookup with a session filter:

  • base.id exact_string ID AND base.session_id exact_string SESSION
  • isa daqsystem AND base.session_id exact_string SESSION, sometimes also AND a regexp

Measured in NDI on a ~100,000-document database:

  • isa daqsystem + session took 5.5 s, returning 3 documents.
  • Adding a base.name regexp (.*) term added +5.2 s.

Three causes:

  1. The AND. did.database/search_doc_ids ran every ANDed term as its own query over the whole branch, then intersected the results in MATLAB. So the session term returned one row per document in the session, and a regexp tested every document.
  2. isa. It matches meta.class exactly, OR runs regex() on the comma-joined meta.superclass list. The regex can't use an index. SQLite built a temporary index over all of doc_data, then ran the regex once per document: 3.0 s on a synthetic 100,000-document database.
  3. No index on doc_data.doc_idx. A term could not be checked against just a few documents.

What changed

Indexes

sqlitedb.searchIndexes defines two indexes:

  • doc_data_doc_idx on (doc_idx)
  • doc_data_field_value on (field_idx, value)

DID-python's _ensure_indexes has created these with the same names and columns for some time. So a database touched by both libraries carries one copy of each.

  • New databases get both.
  • An older database gets them when it is opened (ensure_search_indexes).
  • A file that can't be written is left alone. hasSearchIndexes() is then false, and searches run exactly as before.

AND terms (search_doc_ids)

  • Terms are searched in this order:
    1. base.id lookups
    2. other terms (such as isa)
    3. base.session_id
    4. terms that test every value (regexp, contains_string, hasmember)
  • The search stops at the first term that matches nothing.
  • Once the result is down to at most 1000 documents, later terms only check those documents. That query uses docs.doc_id IN (...), a CROSS JOIN order, and INDEXED BY doc_data_doc_idx.

So isa daqsystem AND session AND regexp finds the 3 daqsystem documents first. It then checks their session, then runs the regexp on their 3 names, whatever order the query was written in.

isa and ORed ids

  • isa (unrestricted, not negated) runs its regex only on the distinct superclass lists, read from doc_data_field_value. LIMIT -1 stops SQLite from pushing the regex back onto every row.
  • ORed base.id lookups run as one query per 1000 ids (search_base_ids).

Timings

SQL only (Python sqlite3), on a synthetic copy of this schema: 100,000 docs, 3M doc_data rows.

before after
isa daqsystem AND session (3 docs) 2.44 s ~0.03 s
isa element AND session (99,999 docs) 2.92 s ~0.6–1.2 s
base.id AND session 0.167 s 0.0002 s
a term checked against 1000 candidates — 0.03–0.08 s (hasfield 0.34 s)
one-time index build on open — ~3 s

The before figures leave out MATLAB converting and intersecting the large result sets, which adds to them in NDI.

On the synthetic database, isa/~isa return the same results under the old and new SQL. For each other kind of term checked, the restricted search returns the full search's results limited to the candidates.

Tests

tests/+did/+unittest/TestSearchIndexes.m, with the helper did.test.helper.QueryLogSqlitedb (it records each query's row count).

  • Indexes: present in new databases; added to an older one when it is opened.
  • Id lookups:
    • alone;
    • ANDed with a session in either order, where no query may return more than 1 row;
    • in the wrong session;
    • unknown ids;
    • ORed ids, including more than 1000.
  • isa AND session, in either order.
  • regexp AND isa AND session, in three orders.
  • isa/~isa: the same results with and without the indexes.
  • Restricted vs full search: the same results for each kind of term.

CI on 83a2bda:

  • 450 MATLAB tests pass, 13 more than main.
  • The MATLAB ↔ Python symmetry tests pass.
  • Codecov: 91% of the new lines are covered; project coverage rises from 65.7% to 66.3%.

The timings above have not been re-measured in NDI itself.

DID-python

The port is VH-Lab/DID-python#98. It uses the same index names, the same add-on-open behaviour and the same search order. Python's own timings, main → that branch:

  • isa daqsystem AND session: 0.30 → 0.027 s
  • with a regexp added: 0.60 → 0.027 s

Its bridge entries point at 83a2bda on this branch. If this PR is squash-merged, they need re-pointing to the squashed commit.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Dv7SxVR8MCu3MKpUvwwUia

An NDI id lookup - base.id exact_string AND base.session_id exact_string -
took ~1 s in a database of 100,000 documents. The base.id term itself was
already an index lookup; the cost was the AND: search_doc_ids searched each
term over the whole branch and intersected the results in MATLAB, so the
session term returned (and MATLAB converted and intersected) one row per
document in the session.

- search_doc_ids searches base.id lookups first, stops as soon as an AND
  term matches nothing, and once the result is down to at most
  maxSearchCandidates (1000) documents, checks the remaining terms against
  those documents only (docs.doc_id IN (...), with CROSS JOIN fixing the
  loop order so each candidate's doc_data rows are reached by index).
- ORed base.id lookups (nested to any depth) are searched in one query per
  1000 ids instead of one query per id.
- New index doc_data_doc_idx_field_idx ON doc_data (doc_idx, field_idx),
  created with new databases and added when an older database (DID-matlab
  or DID-python) is opened. A file that cannot be written is left alone and
  searched exactly as before (canSearchCandidates is false then).
- The index also speeds up unrestricted searches that scan doc_data per
  document (isa 2.4 s -> 0.34 s, hasfield 1.6 s -> 0.56 s, contains_string
  0.6 s -> 0.16 s on a synthetic 100,000-document database), and the
  DELETE FROM doc_data WHERE doc_idx=? done when a document is removed.

SQL-level timing on that synthetic database (3M doc_data rows): id AND
session lookup 0.167 s -> 0.00014 s; building the index once: 0.9 s.

Tests: TestBaseIdSearch (with did.test.helper.QueryLogSqlitedb, which
records the row count of each query) - id lookups alone, ANDed with a
session in either order, in the wrong session, unknown ids, ORed ids
(including more than 1000), restricted vs full search agreement for every
kind of term, and the index being added to an older database on open.
Not run here: no MATLAB in this environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv7SxVR8MCu3MKpUvwwUia
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

450 tests  +13   450 ✅ +13   2m 27s ⏱️ +9s
 39 suites + 1     0 💤 ± 0 
  1 files   ± 0     0 ❌ ± 0 

Results for commit 83a2bda. ± Comparison against base commit f4a4905.

♻️ This comment has been updated with latest results.

@codecov

codecov Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.34615% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.28%. Comparing base (f4a4905) to head (83a2bda).

Files with missing lines Patch % Lines
src/did/+did/database.m 90.80% 8 Missing ⚠️
src/did/+did/+implementations/sqlitedb.m 94.11% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #229      +/-   ##
==========================================
+ Coverage   65.71%   66.28%   +0.56%     
==========================================
  Files          66       66              
  Lines        4579     4674      +95     
==========================================
+ Hits         3009     3098      +89     
- Misses       1570     1576       +6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…erms last)

'isa' matches meta.class exactly OR runs a regex on the comma-joined
meta.superclass list. That regex can't use an index, so over a whole branch
SQLite built a temporary index over all of doc_data and ran the regex once
per document: 3.0 s on a synthetic 100,000-document database (5.5 s
measured in NDI for isa daqsystem AND base.session_id, returning 3 docs).

- New index doc_data_field_idx_value ON doc_data (field_idx, value), next
  to doc_data_doc_idx_field_idx; both are listed in sqlitedb.searchIndexes,
  created with new databases and added when an older one is opened.
  canSearchCandidates is renamed hasSearchIndexes and is true when both
  exist.
- Unrestricted, non-negated 'isa' runs the regex only on the DISTINCT
  superclass lists (read in order from the new index; LIMIT -1 keeps
  SQLite from pushing the regex back onto every row), then finds the
  documents holding a matching list through the same index: 41 regex
  calls instead of 100,000 in the synthetic database.
- In an AND, base.session_id terms are searched last, so a selective term
  (isa daqsystem) narrows the candidates before the session term is checked.
- A restricted query names doc_data_doc_idx_field_idx with INDEXED BY: with
  both indexes present the planner otherwise read the session's rows
  through (field_idx, value), 100,000 rows per candidate.

Synthetic database, SQL only (Python sqlite3):
  isa daqsystem AND session (3 docs)   2.44 s -> 0.024 s
  isa element AND session (99,999)     2.92 s -> 0.62 s
  one-time build of both indexes        ~2.5 s
Results match the old SQL for isa/~isa, and restricted vs full searches
match, for every kind of term checked.

Tests: TestBaseIdSearch becomes TestSearchIndexes, adding isa AND session in
either order (rows returned bounded by the isa matches), isa/~isa identical
with and without the indexes, whole-name matching, and both indexes added
to an older database. Not run here: no MATLAB in this environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv7SxVR8MCu3MKpUvwwUia
@stevevanhooser stevevanhooser changed the title sqlitedb: make base.id lookups fast (doc_data index + candidate-restricted AND terms) sqlitedb: make id and isa searches fast (doc_data search indexes, restricted AND terms) Oct 24, 2026
… AND

These operations test every value they see, so over a whole branch they
cost a scan of every document (NDI measured +5.2 s for a base.name regexp
matching everything, ANDed with isa and base.session_id). Ranking them
after the other terms means they run on the few documents those terms
left, through the candidate restriction, whatever order the query gave.

Test: regexp AND isa AND session, in three orders, returns the right
documents with no query returning more rows than isa matched. Not run
here: no MATLAB in this environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv7SxVR8MCu3MKpUvwwUia
@stevevanhooser stevevanhooser changed the title sqlitedb: make id and isa searches fast (doc_data search indexes, restricted AND terms) sqlitedb: make id, isa and regexp searches fast (doc_data search indexes, restricted AND terms) Oct 24, 2026
DID-python has created doc_data_doc_idx ON doc_data(doc_idx) and
doc_data_field_value ON doc_data(field_idx, value) on every open for some
time, so databases it has touched already carry them. Creating the same
indexes under other names (doc_data_doc_idx_field_idx,
doc_data_field_idx_value) would have given such a database two copies of
each. The search uses them the same way; INDEXED BY now names
doc_data_doc_idx. On the synthetic 100,000-document database, checking a
term against 1000 candidates through the single-column index takes 0.02-0.08
s; the NDI lookups (id or isa AND session) are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv7SxVR8MCu3MKpUvwwUia
@stevevanhooser
stevevanhooser merged commit 8ae33fd into main Oct 24, 2026
6 of 7 checks passed
@stevevanhooser
stevevanhooser deleted the claude/did-base-id-index branch October 24, 2026 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants