8000 perf: point query on a bound entity scans the entity's whole version history — O(retract/reassert count), not O(live facts) · Issue #323 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content

perf: point query on a bound entity scans the entity's whole version history — O(retract/reassert count), not O(live facts) #323

Closed

Description

@adityamukho

Summary

A point query on a bound entity costs O(total versions ever written for that
entity), not O(live facts). An entity whose attribute is retracted and
reasserted N times answers a :find for its single live value ~N times slower
than a freshly written one, at identical graph size. Any "current value of a
mutable cell" pattern therefore degrades linearly over its own lifetime.

Reproducer

One entity, one attribute, retracted and reasserted in a loop. Exactly one
fact is live at every measurement.
Filler is checkpointed and constant within
each arm; the query is (query [:find ?v :where [:e/hot :hash ?v]]), median of
20 calls after a checkpoint.

db.execute(f'(retract [[:e/hot :hash "{prev}"]])')
db.execute(f'(transact {{:valid-from "{TS}"}} [[:e/hot :hash "{cur}"]])')
chain depth 10k filler 100k filler
1 0.128 ms 0.115 ms
100 0.426 ms 0.463 ms
500 1.797 ms 1.820 ms
1000 3.475 ms 3.522 ms
2000 6.782 ms 7.008 ms
4000 13.826 ms 13.514 ms

107.7x / 117.3x growth, dead linear in depth at roughly 3.4 µs per dead
version. A 10x change in total graph size moves the curve by noise, so this is
the entity's own version count and nothing else.

Mechanism

DatalogExecutor's bound-entity path (src/query/datalog/executor.rs:416)
calls get_facts_by_entity(uid) and pushes every returned fact into
all_facts, deduping on (entity, attribute, tx_count, asserted). Valid-time
and assertion filtering happen downstream of that materialization.

FactStorage::get_facts_by_entity (src/graph/storage.rs:622) range-scans
EAVT from (entity, "", i64::MIN, i64::MIN, 0) to the next entity id. The
EavtKey includes valid_from, valid_to and tx_count, so every
retract/reassert cycle adds another key under the same entity, and each hit
costs a resolve_fact_ref — a loader.resolve(fr) page load for committed
facts.

So the scan returns the entity's entire history and the executor pays a page
resolve per dead version before discarding all of them.

Why this bites

The pattern it penalises is "a fixed ident holding a mutable current value" —
watermarks, cursors, counters, config cells. In
project-minigraf/temporal_reasoning three such idents are updated once per
ingested commit, so each is O(commits processed) to read and the run is O(N²) in
that term. Measured there: the query component of the per-commit update grows
22.80x over 6000 cycles while being invariant across a 50x graph-size sweep
(1.12 / 1.12 / 1.09 s at 100k / 1M / 5M filler).

Every one of those queries wants exactly one live fact.

Possible directions

Offered as context, not as a prescription — the right fix depends on internals I
can only read from outside.

  1. Push the "currently live" predicate into the scan. The common case is a
    plain [:e/x :attr ?v] with no :as-of / :valid-at, i.e. only
    valid_to IS NULL assertions can match. If liveness were part of the key
    ordering or a skip condition, the scan could stop at the live tip.
  2. A live-tip index. The feat(wasi): Phase 8.1b — wasm32-wasip1 server-side WASM support #152 / perf: avoid per-EAV allocations in net assertion #236 pattern in the downstream repo — a
    companion structure keyed by (entity, attribute) pointing at the current
    version — replacing a scan with a lookup.
  3. Bound the scan when the query has no temporal clause, leaving the full
    history walk for queries that actually ask for it.

Related in spirit to #248 and #250 (pushing predicates to the earliest valid
position), applied to the temporal predicate rather than to not / or.

Notes

  • Version measured: minigraf 1.2.3, release build via the packaged
    libminigraf_ffi.so.
  • retract and transact themselves are flat in chain depth (measured 1.08x
    and 1.16x over 6000 cycles) — the growth is entirely on the read path.

Refs #248
Refs #250

Activity

  1. added this to the v1.6.0 milestone on Aug 24, 2026
  2. adityamukho commented on Sep 19, 2026

    @adityamukho
    CollaboratorAuthor

    Downstream impact measured on a real workload: ~⅓ of temporal_reasoning's ingestion wall clock

    Measured on minigraf 2.0.0 with a full git-history ingestion (957 commits) by project-minigraf/temporal_reasoning. Its ingestion retracts and re-asserts the same entities' facts over and over as lineage moves, so heavily rewritten entities build up long version chains. Details: project-minigraf/temporal_reasoning#239 (issuecomment-5740728463).

    Point queries on a bound entity

    Bound-entity point queries ([<ident> :attr ?v]) account for 1,086 s of a 3,331 s run (32.6%), across about 2.5M calls. Timed against every one of the 3,989 code entities on the finished graph:

    query p50 p90 p99 max
    [<ident> :introduced-by ?c] 0.43 ms 1.18 ms 1.45 ms 10.5 ms
    [<ident> :modified-in ?c] 0.42 ms 1.18 ms 1.48 ms 11.1 ms
    same shape, entities that are rarely rewritten 0.08 ms 0.09 ms 0.13 ms 0.27 ms

    Two different attributes cost the same on the same entity, and the slowest entities are the ones rewritten most. That fits this issue's mechanism: cost follows the entity's total version history, not the attribute's live facts. A synthetic bench over entities that were written once and never retracted measured 0.06 ms/call on graphs of 100k–5M facts, which is 7–16x lower than the real per-call average.

    Possibly beyond this issue: attribute-only scans look history-bound too

    The same finished graph (median of 5 runs):

    query rows returned median
    [?e :introduced-by ?c] 3,989 991 ms
    [?e :modified-in ?c] 4,173 995 ms
    [?e :ident ?i] (rarely rewritten) 5,084 34 ms

    The first two queries return about the same number of rows as :ident but take roughly 30x longer. Those two attributes are the heavily churned ones. That suggests the attribute path (get_facts_by_attribute) also pays for dead versions. I haven't traced that in the source, so please treat it as an observation rather than a diagnosis. It matters downstream because it rules out the usual workaround of swapping a loop of point queries for one set-at-a-time scan: on this graph the scan costs as much as the loop it would replace.

    What this blocks

    temporal_reasoning#239 is parked on this issue. The only fix available downstream is an in-process write-through cache, which carries real invalidation risk. A fix here would remove about a third of ingestion time with no downstream changes. The retract path looks affected too: 223k retracts at 1.38 ms each, another 9.3% of the run.

  3. adityamukho commented on Sep 26, 2026

    @adityamukho
    CollaboratorAuthor

    Closing as mitigated on the v2.x line. The rest of the work is tracked in #379 (v3.0.0).

    Shipped on main (in the next v2.x release, v2.3.0):

    Still open: a heavily rewritten attribute costs O(its history) per read. v7 index keys carry neither the value nor the asserted flag, so liveness can only be decided after resolving every record. The structural fix needs the v8 keys, which exist only on the v3 branch. It is tracked in #379, which computes net-assert on the keys before resolving so resolve cost becomes O(live).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      0