Repository navigation
Description
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"
Activity
fix(query): include asserted flag in selective_fact_fetch dedup key (#…