8000 bug: same-transaction multi-valued facts read back as ONE value, differing between EAVT and AEVT; a batched retract can leave retracted values visible · Issue #371 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content

bug: same-transaction multi-valued facts read back as ONE value, differing between EAVT and AEVT; a batched retract can leave retracted values visible #371

Closed

Description

@adityamukho

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 db

Observed (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.

  1. Index keys. EavtKey/AevtKey have 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 pending BTreeMaps. After a checkpoint they are sorted with sort_unstable_by in build_sorted_index_entries (src/storage/persistent_facts.rs:1197,1215), so the surviving or first-seen entry can differ between EAVT and AEVT.
  2. Query-time dedup. selective_fact_fetch (src/query/datalog/executor.rs, around 407–437 at ccdc85e) 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 by get_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 reaches net_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

  1. adityamukho commented on Sep 25, 2026

    @adityamukho
    CollaboratorAuthor

    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 main releasable as v2.x, the work is being moved to a long-lived v3 branch:

    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.

  2. adityamukho commented on Sep 26, 2026

    @adityamukho
    CollaboratorAuthor

    Reopening. #377 fixed this on the v3 branch, but #378 moved that fix off main. 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/retract call.

    This also covers #287.


    Generated by Claude Code

  3. added this to the v3.0.0 milestone on Sep 26, 2026
  4. added
    known-issueBug present in a published release and not yet fixed in one; listed in the pinned known-issues issue
    on Sep 27, 2026
  5. adityamukho commented on Oct 6, 2026

    @adityamukho
    CollaboratorAuthor

    Fixed on v3 for 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.

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

    bugSomething isn't workingdata-integrityData integrity issueknown-issueBug present in a published release and not yet fixed in one; listed in the pinned known-issues issue

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      0