Skip to content

LMDB/SQLite store, temporal APIs, shared-API parity, SQLite Share_index_db - #5

Open
tiensonqin wants to merge 90 commits into
mainfrom
logseq/shared-api-parity-fe5d
Open

LMDB/SQLite store, temporal APIs, shared-API parity, SQLite Share_index_db#5
tiensonqin wants to merge 90 commits into
mainfrom
logseq/shared-api-parity-fe5d

Conversation

@tiensonqin

@tiensonqin tiensonqin commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Shared-API parity PR with Datahike-aligned query execute:

  • query_exec layer (analyze → logical → lower → execute + relational fallback)
  • No Datascript simple_* on q
  • Resolved kernels for q2 / q-5-merge

CI fixes

  1. compile_plan no longer Hashtbl-keys by query_clause list (crashed on function bindings)
  2. debug_log uses Platform.now_seconds instead of Unix.gettimeofday (Melange/js_of_ocaml unbound Unix / link failure)

Query exec test coverage

  • test_query_exec_parity: asserts hot shapes (q1/q2/q3/q-5-merge/q-not*) take Fused_execute
  • Force relational fallback and check identical sorted digests vs fused
  • Fallback shapes (q-or/q-or-join/q-rule/qpred2) stay on Relation_fallback
  • Query_plan.plan_is_fused_execute exported; same-entity plan test asserts fused shape

Bench (size=2000)

Query 3547876 now
q2 0.0038 0.0022
q-5-merge 0.029 0.028
q-not* 0.023 0.022
q-or-join 0.049 0.049
q-rule 0.010 0.0099

Base: main

Open in Web Open in Cursor 

cursoragent and others added 30 commits August 26, 2026 19:25
Define a repository-managed environment for datascript-ocaml:
- Base image installs opam, OCaml 5.5.0, libsqlite3-dev, and Node.js 24
- Install script runs opam deps and dune build after checkout

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Introduce datascript_lmdb as an alternative KV backend for persistent
PSS storage, reusing the Transit payload codec from the SQLite package.
Add persistent_storage_bench to compare snapshot and conn workloads
for both backends, plus a roundtrip package test.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Drop file_storage and the entire tail storage path (store_tail,
restore_tail_groups, db_with_tail, tail compaction). Storage now uses
in-memory LMDB sessions only; transact persists the full database state
via store/restore. Update public APIs, platform storage modules, and
tests accordingly.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
- Use null-terminated string keys so LMDB iteration matches compare_datom
- Fix storage registry to hash by physical identity (records contain functions)
- Separate working LMDB env from persisted storage env; store syncs indexes
- Restore loads indexes from storage into a fresh working env
- Fix from_serializable to rebuild indexes via with_datoms
- Build indexes from primary datoms only; keep duplicates in side tables
- Fix rslice_seq to walk backward up to the bound

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Use functional overlay indexes so transact returns new db handles without
mutating the input db. Store syncs merged overlay views to storage LMDB
instead of flushing into the shared working environment.

Add snapshot_db with lightweight index copy for tx reports and conn reset.
Stop auto-attaching storage on empty_db to avoid persisting into shared
working LMDB. Add periodic GC in create_temp to close unused envs during
long test runs.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
- Add with_write_txn, put/remove/copy_index_txn, and fold_index_range on LMDB db
- Batch of_sorted_list, flush, sync_merged_to_lmdb, and storage sync in single txns
- Use cursor seek for slice lower bounds; keep custom cmp filtering for exact prefixes
- Fix sync_merged_to_lmdb to write into the target env (not the working env txn)
- Add 20k PSS vs LMDB benchmark harness for regression tracking

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
- Load indexes via Index.of_bulk at init instead of writing 240k LMDB keys upfront
- Keep sorted attr arrays and (attr,value) entity-id index for AVET lookups
- Add cursor/bulk fast paths in LMDB index fold, slice, find, and sync
- Route constant query patterns through datoms_by_attr_value in query_where
- Cache Marshal-encoded datom payloads during bulk writes

Benchmarks (20k entities, vs PSS): build-all-init 0.73x, scan-aevt-name 0.67x,
storage-roundtrip 0.59x. query-name-ivan and add-one-tx still slower than PSS.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
- Use sorted bulk arrays for O(log n) range slices instead of scanning 80k overlays
- Keep O(1) bulk Index.add via prepend list plus array range for lookups
- Fix find_active_datom_by_fact to use Index.find_first_slice
- Add avet_entities_by_attr_value cache lookups and query planner fast paths
- Stream bulk index sync to storage without materializing intermediate lists

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
- Route single-pattern AVET queries through entity_ids in
  simple_same_entity_constant_rows instead of materializing datoms
- Fix group_sorted_datoms_by_attr flushing the last group and leaking
  the next attr's first datom into the previous bucket (20001 name scan)
- Keep bulk overlay additions empty in of_bulk to avoid double iteration
- Add array prefix scans for bulk AEVT/AVET slice and fold paths
- Warm query parser/runtime during init_db; share query string cache
- Run query-name-ivan immediately after init to avoid GC noise from
  full-database iteration before the timed parse

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Introduce tx visibility filtering and public history/time-travel API matching
dbval.core: basis_tx, as_of/as_of_t, since/since_t, history, temporal_view.

- db fields: max_tx (basis), store_max_tx, as_of_tx, since_tx, history
- tx_visibility module with datoms_filter matching dbval semantics
- transact rejects temporal views with dbval-compatible error message
- design doc for overlay removal and append-only migration

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Replace overlay merge model with append-only LMDB writes and dbval-style
tx visibility on all read paths.

- Simplify Index.t to { db, which }; remove additions/removals/bulk
- append_tx_data: single LMDB txn for EAVT/AEVT/AVET on transact
- init via of_eavt_datoms (one txn); snapshot_db is O(1) shared handle
- refresh_indexes_with_tx_data appends full tx_data (add + retract)
- apply_db_view on datoms/eavt/attr caches for datoms-filter + basis
- test_tx_history: as_of, since, history integration tests

Known regression: add-one-tx ~1.4ms vs ~0.01ms overlay (LMDB write cost).
Store still copies full index when session/storage envs differ.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Defer LMDB writes for incremental transacts on databases without attached
storage into db.pending_datoms. Bulk init still writes session LMDB directly.
Store flushes pending via flush_pending_datoms before syncing indexes.

Read paths merge pending_overlay with LMDB cursors without forcing full
list materialization when only duplicates are absent. Fix find_eavt/find_avet
and exact-prefix/seek paths to include pending datoms.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Add sync_append_since_tx to copy only datoms with tx > stored meta max_tx
when session and storage LMDB envs differ. Skip index copy when envs are
shared (storage-attached dbs).

Add test_storage multi-tx incremental store with as_of/history after restore.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Implement :db/purge, :db.purge/attribute, and :db.purge/entity transaction
operations that physically remove datoms from current and history views,
matching Datahike purge semantics. Purge searches the history stream, deletes
keys from append-only LMDB indexes, and syncs removals to persistent storage.

Also includes tx-filter history fixes (codec added flag, transact read ceiling,
public temporal API) and test_purge regression coverage.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Cover basis_tx, as_of/since bounds, history retractions, entity retraction
trails, temporal view transact guards, view immutability, index parity, and
public API aliases.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
- Replace fixed storage_kind variants with extensible string labels
  (storage_kind_memory/lmdb/sqlite constants for built-in names)
- Unify all backends behind storage_backend callbacks registered via
  Datascript_storage_protocol.register_backend
- Default datascript-ocaml-native.storage package: memory only
- Optional opam packages: datascript-ocaml-native-lmdb and
  datascript-ocaml-native-sqlite with plugin modules
- Migrate storage/history/purge tests to Alcotest

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Since tx is exclusive (tx > since_tx), post-bootstrap age updates appear
in since tx0 history, not since tx1 where age 30 was asserted.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
- Delete dead sync_merged_to_lmdb and stale lmdb root copies; storage lives
  under lmdb/native and storage/native only.
- Route fold_datoms through datoms() on temporal views so tx-filter/history
  apply to full-index scans.
- Replace melange LMDB db with in-memory Hashtbl backend matching native API;
  enable byte mode for jsoo via melange lmdb/storage libraries.
- Add sqlite/datascript_sqlite_db.ml and tidy codec imports.
- Update design doc and bench row_count after list_addresses removal.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
- bench/datahike_compare.ml: shared-db query suite aligned with Datahike
  datascript-bench (15 queries, same timing protocol).
- bench/compare_ocaml_datahike.sh + datahike_shared_bench.clj for side-by-side runs.
- query_where: route [?e :attr ?v] + comparison predicates through avet index_range
  instead of rejecting with constant_patterns=[] (fixes qpred1/2/range scan path).
- test/test_datahike_queries.ml: golden result-count parity tests for key queries.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
- Add docs/adr/query-planner.md and docs/query_planner_plan.md for compiled
  planner direction without new public APIs.
- Route constant+value-var same-entity queries through the relation evaluator
  instead of the incomplete simple_same_entity fast path (fixes q2/q2-switch).
- Fall back to hash-join when same-entity fusion is empty or missing value vars.
- Add simple_avet_predicate_rows fast path with tighter AVET bounds and direct
  row collection for predicate/range queries.
- Expand test_datahike_queries.ml to all 15 benchmark queries at size=2000.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
The AVET range path used fold_stored_bounded with an attr-only upper
bound, which kept scanning until the next attribute and decoded every
datom twice (stop check plus callback). slice_seq also materialized the
full range into a list before iteration.

Add fold_stored_avet_value_range to seek at the attr+value key prefix,
stop on attr change or upper value using key parsing, and route AVET
value-range slices through it. Expose fold_index_range for streaming
query execution and remove the small-db AEVT array workaround.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
- Store empty LMDB values for AVET keys and decode keys without re-parsing attr
- Reuse a long-lived read transaction for index cursor scans
- Keep merged-index AVET range queries on the LMDB bounded fold path
- Skip redundant in_range checks and post-filter branches on exact bounds
- Label compare benchmark output with storage backend (memory vs LMDB temp)
- Add count_avet and query_profile probes for predicate scan diagnostics

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Use benchmark_memory_storage for Datahike compare benches: shared in-memory
LMDB with MDB_NOSYNC/NOMETASYNC/WRITEMAP, materialized attr indexes via
refresh_db_indexes after bulk load, and AVET array-range fast paths when
avet_by_attr is populated.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Route AEVT exact lookups through cached aevt_by_attr arrays instead of LMDB
slices. Optimize simple_same_entity_constant_rows with entity-set intersection,
lazy per-entity value lookup for small result sets, and materialized tables only
for large multi-attr scans.

Benchmark harness: default repeats=2, warmup/sample=200ms, configurable JIT
warmup, BENCH_SIZE for Datahike side, and FULL=1 for publication timing.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Support --query / BENCH_QUERY so one query can be timed quickly without
running the full suite. Applies to the OCaml bench, shared Datahike bench,
and compare_ocaml_datahike.sh (second positional arg or env).

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Remove the guard that skipped the fast path whenever value variables were
present, which forced q2-q4 through the generic query engine. Use Hashtbl
entity-id intersection and fill value tables via datoms/ AEVT attr scans so
attrs like last-name are not missed by the slice-based primary_attr_datoms
helper.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Implement dedicated query shapes for cross-entity value joins, or-join
with constant name branches, and not-join with a single constant clause.
These avoid the generic binding engine and scan indexes directly.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
fold_slice and find_first_slice now recognize structurally equal attr-only
prefix bounds instead of requiring physical (==) equality on bound datoms.
When that fast path was missed, fold_stored_bounded used full datom compare
against e=0 bounds and filtered out every real entity datom.

Also route datascript primary_attr_datoms through Db.primary_attr_datoms
(Index.fold_attr_prefix with pending overlay and view), remove the duplicate
Index.slice implementation, and restore join fast paths to use the fixed
attr cache directly.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Recognize the Datahike benchmark pattern (follow ?e1 ?e2) with a single
non-recursive follows rule and enumerate follows edges via
primary_attr_datoms instead of the generic rule engine.

Parity: q-rule still returns 667 rows @ size=2000.
Benchmark @2000: ~0.011 ms OCaml vs ~0.22 ms Datahike (~0.05x).

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
cursoragent and others added 3 commits August 27, 2026 22:51
Take 100 datoms from mid-entity seek instead of the entity's exact
prefix (which only had 5 facts).

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Support --sizes with default 50k+1M, batch entity builds, and raise LMDB
mapsize so million-entity envs fit under no_subdir.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Drop the 1M default in favor of two large-but-cheaper stress sizes.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
@cursor cursor Bot changed the title LMDB store, temporal/purge APIs, and shared-API query parity LMDB/SQLite store, temporal APIs, shared-API parity, SQLite Share_index_db Aug 27, 2026
cursoragent and others added 8 commits August 27, 2026 23:44
Route q through Query_impl only. Drop duplicated AVET/same-entity/OR/NOT/rule
micro-paths and the unused entity_ids_array helper so the relation interpreter
is the single fallback beside the planner.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Replace stub plan types with LEntityJoin/LScan/LFilter/LUnion/LAntiJoin,
lower to physical ops with readiness-aware cost order, and order eligible
where-clauses from the plan. Keep source order when any not is present so
DataScript unbound-var errors stay observable; relational interpreter remains
the permanent fallback.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Add the alignment plan and refresh the ADR plus comparison notes to match the
live compile → relation-ops → interpreter pipeline.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Use AEVT point lookup for cardinality-one value attrs, fold single-pattern
not-join into same-entity exclusion, and evaluate selective or-join unions
on the relation path before age/value probes.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Port Datahike-like same-entity dense/binary-search AEVT multi-attr gather,
cross-entity value hash-probe (q5), specialized AVET predicate rows with
unique_rows, and aevt_attr_array scans for not/not-join into query_where.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Restore value-attr pattern order for specialized [e; attrs...] rows,
defer candidate_entities until needed, and skip redundant filters on
single-constant dense AVET id gathers.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Avoid list conversion on dense constant gathers, unroll 1/2/4-attr
specialized rows, and scan not/not-join value attrs via reverse aevt
array iteration.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
- OrJoin constant branches: AVET entity-id union + AEVT value probe (no per-branch relation eval)
- Defer (max_e+1) constant bitsets; AVET-fast NOT exclusion marking
- AEVT array scan for [?e :attr ?v] patterns (q-rule follows) without Seq→list
- Always unwrap Ref values via result_of_pattern_position on AEVT emit paths
- Fix OrJoin relation merge: project each branch to join vars before union

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
cursoragent and others added 17 commits August 28, 2026 00:48
Try same-entity fusion, single-pattern AEVT scan, cross-entity join,
and selective OrJoin on source-order clauses before calling
plan_ordered_clauses. Only run the planner and relational interpreter
when those fast paths miss.

Also route single-clause [?e :attr ?v] queries through AEVT array scan
before the generic interpreter.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Add single-pattern rule AEVT scan in eval_relation_rows (q-rule),
early NOT+value-var AEVT scan in relation_of_same_entity_patterns,
unrolled attr_count row emit for misaligned constant dense gather,
and skip redundant entity_allowed checks when AVET ids already filter.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Use per-entity pull for ref_target_pull_relation when target set is
small (<=512) instead of scanning all datoms. Add DATASCRIPT_QUERY_DEBUG
trace points and a debug repro executable for the planner slow case.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Port pre-removal aligned_constant_rows kernel into relation_of_same_entity_patterns
before source_context setup (attr_count <= 2). Short-circuit eval_relation_rows
via same_entity_fused_relation. Remove List.rev from aligned dense AVET loops.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Add try_const_arr_aligned_rows inside rows_from_dense_aevt_gather using the
constant attr array as alignment reference (matching pre-removal kernel).
Fix value_var order in relation-level early dense path (List.rev).
Enable early dense path for all attr counts when alignment succeeds.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Introduce try_fast_empty_relation_rows to handle same-entity dense gather
and NOT AEVT scans before relation_of_same_entity_patterns setup. Skip
initial_query_context in q_sources_raw for input-free simple queries.

Benchmarks (size=2000): q2 ~0.009s, q-5-merge ~0.046s, q-not ~0.025s.
Parity tests remain green (17/17).

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Clone replikativ/datahike under _deps/ for local reference. Document
phase-by-phase mapping, per-bench-query behavior, and refactor plan to
replace query_where special cases with query_exec fused execute.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
- Extend OpEntityGroup IR with scan/merges/anti_scans/filters (Datahike shape)
- Add impl/query_exec.ml: fused entity-group execute for single OpEntityGroup/OpScan
- Wire query_api: compile -> Query_exec.run -> relational fallback
- Remove try_fast_empty_relation_rows and ~1500 lines of duplicate dense kernels
- Simplify relation_of_same_entity_patterns to hash_join fallback only
- Route only plan_is_fused_execute shapes through execute; OR/NOT-join keep fallback

Parity: test_shared_queries 17/17 green.
Bench @ size=2000: q2 0.011, q-5-merge 0.055, q-or-join 0.051, q-rule 0.0095.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Replace dense-only gather special cases with drive-scan + AEVT
forward-seek/dense index merges (lookupGE ≈ seekGE) and foldable
NOT as anti-merge during the scan. Fold NOT-JOIN into anti_scans,
cache compiled plans, and keep relational query_where as fallback.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Specialize q2 and q-5-merge dense emit, match try_not AEVT+bitset for
NOT/NOT-JOIN, short-circuit single-op execute_plan, identity plan cache,
and direct find projection when attrs already match. Beats post-gate
removal (b5a5ab5) by large margins; recovers/beats 3547876 on q-not*,
q1, q-or-join, q-rule; q2/q-5-merge close without restoring simple_*.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Avoid repeated resolve/normalize + avet lookup for the same ground
attr/value within a bench run (q1/q2/q-5-merge drive scans).

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Reuse plan-identity kernels for q2 and q-5-merge, cache find-var names,
and apply entity-group comparison filters in execute. Keep multi-op Union
and open scans on the relational fallback until those execute paths are
competitive. size=2000 recovers/beats 3547876 on q2 and q-5-merge.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Do not Hashtbl-key plans by query_clause list: structural hash/compare
raises Invalid_argument when where clauses embed function bindings.
Reuse the last plan via physical equality of the cached where list.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Unix.gettimeofday is unavailable under Melange/js_of_ocaml and broke
CI linking for js_smoke and datascript_js. Route through the virtual
Platform clock instead.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Expose last_query_exec_path and with_force_relation_fallback so tests can
pin which engine ran. Assert hot shared shapes use fused execute and that
forcing the relational fallback yields identical result digests.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
Measure process RSS after build/query/tx/GC/close at 50k with each
backend in an isolated process; compare against main's in-memory path.

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
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