8000 selective_fact_fetch drops new assertion when retract+transact share tx_count in WriteTransaction · Issue #285 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content

selective_fact_fetch drops new assertion when retract+transact share tx_count in WriteTransaction #285

Closed

Description

@adityamukho

Summary

When a WriteTransaction contains both a (retract ...) and a (transact ...) for the same (entity, attribute), all pending facts are stamped with the same tx_count at commit(). The selective_fact_fetch path in the query executor deduplicates index results by (entity, attribute, tx_count), so whichever of the two facts is returned second from the index is silently dropped. In practice the retraction comes first (it has valid_from = 0 vs the assertion's wall-clock valid_from), so the new assertion is the one dropped — leaving the post-transition query returning empty results.

Reproduction

use minigraf::Minigraf;

fn main() {
    let db = Minigraf::in_memory().unwrap();

    // assert initial state
    db.execute("(transact [[:order-42 :fsm/state :awaiting-payment]])").unwrap();

    // atomic retract + transact in one WriteTransaction
    let mut tx = db.begin_write().unwrap();
    tx.execute("(retract [[:order-42 :fsm/state :awaiting-payment]])").unwrap();
    tx.execute("(transact [[:order-42 :fsm/state :paid]])").unwrap();
    tx.commit().unwrap();

    // expected: [[:order-42 :paid]]
    // actual:   [] (empty)
    let result = db
        .execute("(query [:find ?order ?state :where [?order :fsm/state ?state]])")
        .unwrap();
    println!("{:?}", result);
}

Root Cause

db.rs — WriteTransaction::commit() stamps every buffered fact with a single tx_count:

let tx_count = self.inner.fact_storage.allocate_tx_count();
let stamped = facts_to_commit.into_iter().map(|mut f| {
    f.tx_count = tx_count;   // ← same value for every fact in the batch
    ...
});

executor.rs — selective_fact_fetch() deduplicates the index results by (entity, attribute, tx_count):

let key = (fact.entity, fact.attribute.clone(), fact.tx_count);
if seen.insert(key) {
    all_facts.push(fact);   // first winner kept; duplicate key → dropped
}

The AEVT index orders facts by (attribute, entity, valid_from, valid_to, tx_count). The retraction fact has valid_from = 0; the assertion has valid_from = <commit timestamp>. So the retraction is returned first from the index scan. Both share tx_count = N, so the assertion hits the dedup set and is silently dropped.

After selective_fact_fetch returns only the retraction, net_asserted_facts correctly removes the old assertion (retraction tx_count > assertion tx_count), but never sees the new assertion at all — yielding an empty result.

Affected path

The bug only manifests when the query takes the selective_fact_fetch path:

// executor.rs — filter_facts_for_query
if !query.uses_rules() {
    match self.selective_fact_fetch(&patterns, 4) {
        Some(facts) => facts,          // ← bug here
        None => self.storage.get_all_facts()?,
    }
}

Queries that invoke a rule bypass selective_fact_fetch (they call get_all_facts()) and are not affected.

Suggested Fix

Change the dedup key in selective_fact_fetch to include asserted (and ideally value), so that a retraction and an assertion for the same (entity, attribute, tx_count) are both retained:

// before
let key = (fact.entity, fact.attribute.clone(), fact.tx_count);

// after
let key = (fact.entity, fact.attribute.clone(), fact.tx_count, fact.asserted);

Adding fact.asserted to the key is the minimal fix. Including the encoded value would be more robust (matches the PendingKey used in load_fact).

Workaround

Wrap the current-state query in a rule invocation to force the get_all_facts() path:

db.execute("(rule [(current-state? ?o ?s) [?o :fsm/state ?s]])").unwrap();
let result = db.execute(
    "(query [:find ?o ?s :where (current-state? ?o ?s)])"
).unwrap();

Version

minigraf = "1.1.1"

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

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      0