Skip to content

perf(core): own batched rows by rowid range and shape them once in DDL order - #1248

Merged
thekevinscott merged 6 commits into
mainfrom
claude/1243-ingest-perf
Oct 5, 2026
Merged

thekevinscott merged 6 commits into
mainfrom
claude/1243-ingest-perf

Conversation

@thekevinbot

@thekevinbot thekevinbot commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1243

Status

Implementation complete; just preflight green locally (0 failing pairs, 41 mutants all caught); both e2e receipts committed. Awaiting CI.

What changed

  • Per-table (batched) hooks own their rows as one rowid range. Db::insert_rows records a single (table, owner, first_rowid, last_rowid) in the new _dirsql_internal_ranges table instead of one _dirsql_internal_rows row per ingested row. get_rows_by_file, files_under and delete_rows_by_file_stmts read both tables, so refresh and the watcher's delete-before-reingest keep their semantics (DELETE ... WHERE rowid BETWEEN first AND last). Tables with a user-supplied INTEGER PRIMARY KEY are covered by taking min/max of the returned rowids.
  • Rows are shaped once, in DDL column order. Db::shape_rows turns a hook's Vec<JsonRow> into ShapedRows { columns, cells: Vec<Vec<Value>> } (strict validation, extra/missing-column messages unchanged); insert_rows binds positionally. The two per-row HashMap rebuilds (json_row_into_row + normalize_against) are gone. refresh_table diffs via ShapedRows::to_maps() so differ::diff_unordered is untouched.
  • persist::drop_user_tables also clears _dirsql_internal_ranges; the cache keeps both internal tables.
  • ARCHITECTURE.md / PARITY.md name the new internal table. query() rejects it like the other _dirsql_* tables (new auth test).

Item 3 (parser stdout copies in command.rs): measured, not changed. from_utf8_lossy is zero-copy on valid UTF-8 and the remaining to_string is one ~36 MB memcpy, under 10 ms on this fixture. Removing it produced no measurable difference across 6 runs, so the change was reverted rather than shipped. No follow-up issue is warranted.

Timings

Release build, transcripts fixture (4096 files, 3 parser spawns, ~37 MB of rows), min of 6, load ~1. Output md5 identical before and after (5ca3f08c04ba5648317060d7b81206c9).

run main efee3e9 this PR delta
ingest only (/tmp/dirsql-tp-feed, pre-generated rows) 0.99 s 0.58 s -41%
full run (/tmp/dirsql-tp-fixture/transcripts) 2.65 s 2.17 s -18%

The issue's bar was 0.55 s for ingest; this lands at 0.58 s. What remains is reading the parser pipe and serde_json deserialising into Vec<JsonRow> (one String per key per row in infer::parse_rows), which is outside items 1-3.

Speed-of-light tests (packages/python/tests/e2e/, uv run pytest -k speed_of_light):

test before after
dossier pass pass
join xfail (strict) xfail (strict)
notebook pass pass
transcripts xfail (strict) xfail (strict)
embeddings (plugins/dirsql-plugin-embeddings/tests/e2e/speed_of_light_test.py) pass pass

Exercised by hand

cargo build --release --features cli
dirsql query -c /tmp/dirsql-tp-fixture/transcripts/.dirsql.toml "SELECT project, role, count(*) AS n FROM messages GROUP BY project, role ORDER BY project, role"

Rows match main byte-for-byte; the migrations fragment's verification snippet runs as written.

E2E Verification

  • Python SDK: just e2e-attest-python -> 40 passed, 2 xfailed (the two strict xfails are the join and transcripts speed-of-light bars, unchanged); receipt packages/python/e2e-attestations/claude-1243-ingest-perf.json
  • TypeScript SDK: just e2e-attest-ts -> 6 files, 18 tests passed; receipt packages/ts/e2e-attestations/claude-1243-ingest-perf.json
  • Rust core: cargo test --workspace --features cli -> 62 suites, 0 failures (one pre-existing port-race flake in cli_e2e::no_config_serves_path_tables_not_a_files_table seen once in six runs; passes alone and in four consecutive suite runs; untouched by this PR)

Changelog / Migrations

  • packages/rust/changelog.d/2026-10-02-batch-rows-owned-by-range.md (Changed)
  • packages/rust/migrations.d/2026-10-02-batch-rows-owned-by-range.md

PARITY

No public SDK surface changed. Db is doc-hidden; _dirsql_internal_ranges is listed in PARITY.md next to _dirsql_internal_rows.

…them once in DDL order

A batched on-file hook paid a `_dirsql_internal_rows` insert plus an index
insert per row, and rebuilt each row through two HashMaps before binding it
by name. The batch is now owned as one `(table, first_rowid, last_rowid)`
range in `_dirsql_internal_ranges`, and its rows are laid out in DDL column
order once (`Db::shape_rows` -> `ShapedRows`) and bound positionally.

Per-file hooks keep the per-row mapping; `delete_rows_by_file`,
`get_rows_by_file` and `files_under` resolve both forms, so refresh and watch
keep their delete-before-reingest semantics and old persisted caches gain
the table on open.

Transcripts fixture, release build, min of 6: ingest only 0.99 s -> 0.58 s;
full run 2.65 s -> 2.17 s; identical rows.
…nused ShapedRows accessors

Unit tests build their JsonRows from json! literals rather than calling the
parser, and the persist sweep tests declare the two internal tables inline
on their raw connection. ShapedRows::len and is_empty had no production
caller. The migration fragment's verification snippet passes -c so it runs
as written.
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.

perf: batched on-file ingestion pays a redundant mapping insert and two HashMap rebuilds per row (half of 1.0 s on the transcripts fixture)

2 participants