Repository navigation
Description
Summary
When one transact writes two values of the same attribute for one entity, a read returns only one of them. Which one depends on the query path, so the EAVT path ([:e :attr ?v]) and the AEVT path ([?e :attr ?v]) can return different values for the same entity. The fact pages still hold both values. A retract that removes both values in one call has the same problem: in most graphs, at least one retracted value is still visible through at least one path.
This belongs with #287 (EavtKey/AevtKey carry no value bytes), but it reaches further than #287 describes, and #287's proposed fix is not enough on its own. Details below.
Environment
minigraf 2.0.0 (Python wheel). I read the source at upstream HEAD ccdc85e.
Reproduction (Python, minigraf package only)
import json, os, sys, uuid
from minigraf import MiniGrafDb
d = sys.argv[1]
q = lambda db, s: json.loads(db.execute(s))["results"]
def reads(db, x):
e = str(uuid.uuid5(uuid.NAMESPACE_OID, x))
eavt = sorted(r[0] for r in q(db, f"(query [:find ?v :where [{x} :kind ?v]])"))
aevt = sorted(v for ent, v in q(db, "(query [:find ?e ?v :where [?e :kind ?v]])") if ent == e)
# nothing bound -> full scan of the fact pages, no index involved
scan = sorted(v for ent, a, v in q(db, "(query [:find ?e ?a ?v :where [?e ?a ?v]])")
if ent == e and a == ":kind")
return eavt, aevt, scan
def build(tag):
p = os.path.join(d, f"{tag}.graph")
for s in ("", ".wal"):
if os.path.exists(p + s): os.remove(p + s)
x = f":t/x{tag}"
facts = [f"[{x} :kind :k/a]", f"[{x} :kind :k/b]", f'[{x} :note "two"]']
facts += [f for i in range(30)
for f in (f"[:t/f{tag}-{i} :kind :k/a]", f'[:t/f{tag}-{i} :note "f"]')]
db = MiniGrafDb.open(p)
db.execute("(transact [" + " ".join(facts) + "])")
return p, x, db
for i in range(8): # after checkpoint + reopen
p, x, db = build(f"h{i}"); db.checkpoint(); del db
db = MiniGrafDb.open(p); print(f"h{i}", reads(db, x)); del db
for i in range(8): # retract BOTH in one retract
p, x, db = build(f"r{i}"); db.checkpoint(); del db
db = MiniGrafDb.open(p)
db.execute(f"(retract [[{x} :kind :k/a] [{x} :kind :k/b]])")
db.checkpoint(); del db
db = MiniGrafDb.open(p); print(f"r{i}", reads(db, x)[:2]); del dbObserved (every run gives the same output)
After checkpoint and reopen. Columns are EAVT / AEVT / fact pages:
h0 [':k/b'] [':k/a'] [':k/a', ':k/b']
h1 [':k/a'] [':k/b'] [':k/a', ':k/b']
h2 [':k/b'] [':k/b'] [':k/a', ':k/b']
h3 [':k/a'] [':k/b'] [':k/a', ':k/b']
h4 [':k/b'] [':k/b'] [':k/a', ':k/b']
h5 [':k/a'] [':k/a'] [':k/a', ':k/b']
h6 [':k/b'] [':k/a'] [':k/a', ':k/b']
h7 [':k/a'] [':k/a'] [':k/a', ':k/b']
Every graph loses one value on both index paths, and in 4 of 8 the two paths disagree about which one. Before any checkpoint, on the same handle, both paths return [':k/b']: the last value, matching #287's "BTreeMap overwrite keeps the last".
Retract both values in one call, then checkpoint and reopen. Columns are EAVT / AEVT:
r0 [] []
r1 [] [':k/b']
r2 [] [':k/b']
r3 [] [':k/b']
r4 [':k/b'] []
r5 [':k/b'] [':k/b']
r6 [':k/b'] [':k/b']
r7 [] [':k/b']
Only 1 of 8 graphs reads the entity as fully retracted through both paths. In 2 of 8, a retracted value is still visible through both.
The disagreement depends on the graph's contents. With one attribute per filler entity instead of two, all 8 graphs lost a value but the two paths agreed. The :note facts are what make EAVT and AEVT order the colliding entries differently.
Why #287's proposed fix does not cover this
From reading the source, not isolated at runtime. Nothing reachable from Python lets me separate the two layers.
- Index keys.
EavtKey/AevtKeyhave no value bytes (bug: EavtKey and AevtKey missing value bytes causes silent data loss when batching multi-valued facts #287). Colliding entries overwrite each other in the pendingBTreeMaps. After a checkpoint they are sorted withsort_unstable_byinbuild_sorted_index_entries(src/storage/persistent_facts.rs:1197,1215), so the surviving or first-seen entry can differ between EAVT and AEVT. - Query-time dedup.
selective_fact_fetch(src/query/datalog/executor.rs, around 407–437 atccdc85e) dedups the fetched facts on(entity, attribute, tx_count, asserted). That key also has no value. So even with bug: EavtKey and AevtKey missing value bytes causes silent data loss when batching multi-valued facts #287 fixed and both facts returned byget_facts_by_entity, the read would still keep only the first one. The same dedup applies to the two retractions of a batched retract, which share(e, a, tx, false). Only one of them reachesnet_asserted_facts, so the other value's assertion is never cancelled.
The fix probably has to change both: add the value to the index keys, as #287 proposes, and to the seen key in selective_fact_fetch. The seen key could use encode_value bytes, as AvetKey already does.
Impact downstream
temporal_reasoning (project-minigraf/temporal_reasoning#339) added a run-start check comparing EAVT and AEVT to catch the silent index damage in #370. Its first version refused healthy graphs because of this bug: 3 of 6 graphs built through its public transact handler, with one entity given two :entity-type values in one transact. It now compares only whether each index returns anything, and it can still refuse a healthy graph after one of those values is retracted. Separately, any temporal_reasoning read of a multi-valued attribute written in one transact returns one value, and which one depends on the query shape. The ingestion code avoids this by writing :contains/:depends-on/:parent one fact per transact (the #287 workaround). Memory writes do not avoid it.
Related: #287 (same root cause, narrower statement), #370 (the index-damage bug the downstream check exists for).
Activity
fix!: file format v8 — keep same-transaction multi-valued facts (#371, …
adityamukho commentedon Sep 25, 2026
Where this fix lives, and when it ships
The fix (#377) changes the index keys and so moves the file format from v7 to v8. That is a one-way migration and a breaking change. To keep
mainreleasable as v2.x, the work is being moved to a long-livedv3branch:v3contains the fix unchanged, atb8a9762. It ships in v3.0.0.mainis the v2.x line. revert: move file format v8 (#377) from main to the v3 branch #378 reverts fix!: file format v8 — keep same-transaction multi-valued facts (#371, #287) #377 there, so v2.1–v2.3 and the bug: after a kill during an active run the EAVT index silently loses most entities' entries while AEVT stays intact — entity-bound lookups return [] and no rebuild path exists #370 fix can ship without forcing the v8 migration.v3mergesmainregularly and merges intomainwhen v3.0.0 is cut.Until v3.0.0: v2.x still has this bug. Workaround: write each value of a multi-valued attribute in its own
transact. Don't batch several values of the same entity + attribute into one call. The same applies to retracting several values of one attribute.The issue stays closed because the fix is merged, on
v3. It will appear in the v3.0.0 CHANGELOG.revert: move file format v8 (#377) from main to the v3 branch (#378)
release: v2.0.1 — fix index rebuild on open (#370)
adityamukho commentedon Sep 26, 2026
Reopening. #377 fixed this on the
v3branch, but #378 moved that fix offmain. So every v2.x release, including v2.0.1, still has the bug. This issue should stay open until v3.0.0 (format v8) is published. See #399 for the rule and #383 for the tracker.Affected: all released versions up to and including v2.0.1.
Fixed in: v3.0.0 (not yet released).
Workaround on v2.x: write or retract each value of a multi-valued attribute in its own
transact/retractcall.This also covers #287.
Generated by Claude Code
docs(roadmap): make v2.0.2 the final v2.x release, split v3.0.0/v3.1.0 (
docs: fix unsafe WAL recovery advice and align docs with milestones (#…
adityamukho commentedon Oct 6, 2026
Fixed on
v3for v3.0.0 by file format v8: index keys carry the encoded value and the assert/retract flag (PR #377, carried through the v8 storage format in #449), so same-transaction multi-valued facts and batched retracts read back correctly on every query path. Regression tests:tests/multi_value_test.rs. A v7 file is re-indexed on its first open under v3.0.0. v2.x, including v2.0.3, is still affected and stays listed in #421 until v3.0.0 ships.test: widen property tests to joins, negation, predicates and time tr…
test: soak checks multi-valued reads by entity+attribute and AEVT (#392)