Skip to content

perf: fresh-entity tx memo + cached schema/attr fast paths - #27

Merged
tiensonqin merged 5 commits into
mainfrom
devin/tx-memo-fresh-entities
Oct 1, 2026
Merged

tiensonqin merged 5 commits into
mainfrom
devin/tx-memo-fresh-entities

Conversation

@tiensonqin

@tiensonqin tiensonqin commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Three related transact/index fast paths, measured on a ~500k-datom bulk import (Logseq 4k-movies graph, out_cli load-dir): the in-transact indexing phase drops from ~12.7s to ~6.4s.

  1. Transact.tx_memo — fresh-entity lookup memo. During a transaction, datoms for entities allocated inside that transaction (d.e > base_max_eid, i.e. they cannot exist in the pre-tx index) are recorded in a (e, a) -> datom list hashtable populated through the refresh_db_indexes_with_tx_data funnel. add_active_datom_with_report_db answers its same-fact (find_eavt_exact) and cardinality-one (entity_attr_datoms_db) lookups from the memo instead of descending the index trees — two index probes per Add avoided. Import-style transactions consist almost entirely of fresh entities. Dedup equality uses compare_value to match index semantics.

  2. Schema.schema_attr_is_avet_accessible — per-schema cached set. It ran two List.assoc_opt scans of the schema assoc list per call, once per index datom insert (~500k calls on import). The accessible-attr set is now computed once per schema and cached, keyed by the schema assoc list's physical identity (same staleness check Schema_access.schema_attr already uses).

  3. Util.compare_attr — memoized normalized key. The upstream (ns, name) pairwise order is equivalent to a byte compare on a normalized key: last '/' replaced by '\x00', or '\x00' prepended when the attr has no '/' ('\x00' sorts below every legal attr byte and below '/', so a shorter/prefix ns and a missing ns both order correctly). Keys are memoized in a content-keyed hashtable, so an attr compare is a ==/String.equal fast path or a single String.compare — this is the largest win since compare_attr leads every aevt/avet index comparator call. compare_slice (only used by the old compare_attr) is removed.

All impl test exes pass (test_datascript, test_upsert, test_db, test_storage, test_pull_*, test_lookup_refs, test_serialize, etc. — everything except the lein-dependent upstream JS bundle rule and the cross_runtime driver that needs --input).

Link to Devin session: https://app.devin.ai/sessions/87d07eb19d674693adeffdb53332c183
Open in Devin Desktop: https://app.devin.ai/desktop/session/87d07eb19d674693adeffdb53332c183?variant=devin
Requested by: @tiensonqin


Devin Review

Within apply_tx, entities allocated inside the transaction (e > the
pre-tx max_eid) cannot exist in the pre-tx index, so their same-fact
and entity/attr lookups are answered from a per-tx table instead of
descending the index trees.  Bulk seed transactions (imports) consist
almost entirely of such datoms.
schema_attr_is_avet_accessible ran a List.assoc_opt scan of the whole
schema assoc list (twice) for every datom inserted into the indexes.
Bulk imports pay that ~500k times; each schema's accessible-attr set is
now computed once and cached by schema-list physical identity, the same
staleness check Schema_access.schema_attr already uses.
The (ns, name) pairwise order equals a byte compare on a normalized key
(last '/' replaced by NUL, or NUL prepended when there is none), so
compare_attr now resolves each attr to a memoized key once and runs a
single String.compare instead of two rindex scans plus two per-char
slice loops on every index comparator call.
@devin-ai-integration

Copy link
Copy Markdown

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin resolved all 3 findings on f21006a

Fixed by Devin (3)

  • Existing transaction entity retains old value
  • Caught nested transaction corrupts outer writes
  • Keyword comparisons retain every distinct value

View all findings in Devin Review

Devin Review

…tr keys

- Memo freshness now requires e <= max_allocatable_entity_id: datoms on
  transaction entities (e >= tx0) can already carry index datoms from
  earlier transactions, so routing them through the empty memo bucket
  dropped the retraction a cardinality-one update needs.
- apply_tx saves/restores the shared memo record (Fun.protect): a nested
  transaction via Call, or an exception raised mid-tx, no longer leaks
  this transaction's state into the outer one — the outer transaction
  also keeps its memo instead of losing it to the inner reset.
- attr_key_tbl is capped (65536 entries): keyword values flow through
  compare_attr too, so an unbounded vocabulary would grow the table
  without bound; past the cap keys are recomputed per call.
- Regression test: cardinality-one update on a past tx entity.
The memo table kept every datom of the transaction that just ran as
dead state until the next transaction reset it — ~2.5MB retained heap
in the memory benchmark (js_of_ocaml initial-open: 10.5MB -> 8.1MB).
@tiensonqin
tiensonqin merged commit 7b92fe8 into main Oct 1, 2026
2 checks passed
@tiensonqin
tiensonqin deleted the devin/tx-memo-fresh-entities branch October 1, 2026 19:54
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.

1 participant