From d6ef58a05cb8f5a63d733614c483f562d5974fc5 Mon Sep 17 00:00:00 2001 From: Dzmitry Kalabuk Date: Wed, 20 May 2026 11:16:56 +0200 Subject: [PATCH 1/3] Fix simplify() routing surviving relations to wrong scan table Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/query/src/plan/plan.rs | 5 ++++- crates/query/src/plan/rel.rs | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/query/src/plan/plan.rs b/crates/query/src/plan/plan.rs index 0391ab08..e0410f01 100644 --- a/crates/query/src/plan/plan.rs +++ b/crates/query/src/plan/plan.rs @@ -726,7 +726,10 @@ impl PlanBuilder { .enumerate() .filter(|(idx, _)| is_full_rel[*idx]) { - let table = rel.output_table(); + // The new scan must read the relation's *input* table, since + // execute_scans feeds scan rows into relation_inputs[rel_idx], + // which eval_join etc. interpret as row indexes of input_table. + let table = rel.input_table(); let scan = new_scans.entry(table).or_insert_with(|| Scan { table, predicate: None, diff --git a/crates/query/src/plan/rel.rs b/crates/query/src/plan/rel.rs index 345eeb2e..d2a53d35 100644 --- a/crates/query/src/plan/rel.rs +++ b/crates/query/src/plan/rel.rs @@ -51,6 +51,16 @@ impl Rel { } } + pub fn input_table(&self) -> Name { + match self { + Rel::Join { input_table, .. } => input_table, + Rel::ForeignChildren { input_table, .. } => input_table, + Rel::ForeignParents { input_table, .. } => input_table, + Rel::Children { table, .. } => table, + Rel::Parents { table, .. } => table + } + } + pub fn eval( &self, chunk: &dyn Chunk, From b5b888650738d25d71c10e8e067be9a5bdd6d50e Mon Sep 17 00:00:00 2001 From: Dzmitry Kalabuk Date: Wed, 20 May 2026 11:17:15 +0200 Subject: [PATCH 2/3] Guard simplify() output with an invariant assert and regression tests Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/query/src/plan/plan.rs | 202 +++++++++++++++++++++++++++++++++- 1 file changed, 201 insertions(+), 1 deletion(-) diff --git a/crates/query/src/plan/plan.rs b/crates/query/src/plan/plan.rs index e0410f01..cc697c78 100644 --- a/crates/query/src/plan/plan.rs +++ b/crates/query/src/plan/plan.rs @@ -739,7 +739,32 @@ impl PlanBuilder { scan.relations.push(idx); } - self.scans.extend(new_scans.into_values()) + self.scans.extend(new_scans.into_values()); + + self.assert_scan_relation_invariant(); + } + + /// Verifies that every scan's relations have an `input_table` matching the + /// scan's table. This is the contract `execute_scans` relies on: rows read + /// from `scan.table` are pushed into `relation_inputs[rel_idx]` and later + /// interpreted by `rel.eval` as row indexes of `rel.input_table()`. A + /// mismatch produces silently wrong row selections that surface as + /// out-of-bounds reads on whichever column is read first. + fn assert_scan_relation_invariant(&self) { + for scan in self.scans.iter() { + for &rel_idx in scan.relations.iter() { + let rel = &self.relations[rel_idx]; + assert_eq!( + rel.input_table(), + scan.table, + "plan invariant violated: scan on {:?} owns relation {} \ + whose input_table is {:?}", + scan.table, + rel_idx, + rel.input_table() + ); + } + } } fn remove_relations(&mut self, remove_mask: &[bool]) { @@ -884,3 +909,178 @@ impl<'a> ScanBuilder<'a> { &mut self.plan.scans[self.scan_idx] } } + + +#[cfg(test)] +mod tests { + use super::*; + use crate::scan::col_gt_eq; + use std::sync::OnceLock; + + /// Tables with a `transactions` parent and `logs` child, plus an explicit + /// `add_child("logs", ...)` registration on transactions. The logs side has + /// no children of its own — so a `logs -> transactions` join is *not* a + /// "full rel" (logs.primary_key != input_key, and logs.children is empty), + /// which is exactly the shape that triggered the production bug. + fn evm_like_tables() -> &'static TableSet { + static TABLES: OnceLock = OnceLock::new(); + TABLES.get_or_init(|| { + let mut t = TableSet::new(); + t.add_table("blocks", vec!["number"]); + t.add_table( + "transactions", + vec!["block_number", "transaction_index"], + ) + .add_child("logs", vec!["block_number", "transaction_index"]); + t.add_table("logs", vec!["block_number", "log_index"]); + t + }) + } + + /// Tables where logs is a child of transactions AND transactions is a child + /// of logs with a matching key. With that, a `logs -> transactions` join + /// where input_key == logs.primary_key would be a "full rel". Used by the + /// full-rel-removal test. + fn full_rel_tables() -> &'static TableSet { + static TABLES: OnceLock = OnceLock::new(); + TABLES.get_or_init(|| { + let mut t = TableSet::new(); + t.add_table("blocks", vec!["number"]); + t.add_table("transactions", vec!["block_number", "transaction_index"]); + t.add_table("logs", vec!["block_number", "log_index"]) + .add_child("transactions", vec!["block_number", "log_index"]); + t + }) + } + + /// Reproducer for the original bug. A predicate-less log selector with a + /// `transaction: true` join (modelled as `add_scan("logs").join("transactions", ...)`) + /// must end up attached to a logs-table scan after simplification — never + /// to the transactions-table scan. Pre-fix, simplify() keyed the new scan + /// by `rel.output_table()`, so the join was reattached to a transactions + /// scan and execute_scans fed transactions row indexes into a logs-input + /// slot. + #[test] + fn simplify_attaches_surviving_relations_to_input_table_scan() { + let mut builder = PlanBuilder::new(evm_like_tables()); + builder + .add_scan("logs") + .join( + "transactions", + vec!["block_number", "transaction_index"], + vec!["block_number", "transaction_index"], + ); + + let plan = builder.build(); + + assert_eq!(plan.relations.len(), 1, "join relation must survive"); + let rel = &plan.relations[0]; + assert_eq!(rel.input_table(), "logs"); + assert_eq!(rel.output_table(), "transactions"); + + let owning_scan = plan + .scans + .iter() + .find(|s| s.relations.contains(&0)) + .expect("some scan must own the surviving relation"); + assert_eq!( + owning_scan.table, "logs", + "relation with input_table=logs must be attached to a logs-scan, not a {}-scan", + owning_scan.table + ); + } + + /// When the relation IS a "full rel" (input fully populated implies output + /// fully populated), simplify() must drop it entirely and replace it with + /// a direct full scan on the output table. No relation should survive. + #[test] + fn simplify_drops_full_rel_and_replaces_with_direct_scan() { + let mut builder = PlanBuilder::new(full_rel_tables()); + builder + .add_scan("logs") + .join( + "transactions", + vec!["block_number", "log_index"], + vec!["block_number", "log_index"], + ); + + let plan = builder.build(); + + assert!( + plan.relations.is_empty(), + "full rel must be removed during simplification, got {:?} relations", + plan.relations.len() + ); + // Both outputs should still be populated by direct scans, with no relations. + for table in ["logs", "transactions"] { + assert!( + plan.scans.iter().any(|s| s.table == table && s.relations.is_empty()), + "expected a relation-less scan for {}; scans = {:?}", + table, + plan.scans.iter().map(|s| s.table).collect::>() + ); + } + } + + /// Mixed plan: one predicate-less log scan and one predicated log scan that + /// joins to transactions. The predicate-less scan goes through the + /// "is_full" path, while the predicated scan must keep its join. The whole + /// plan must still satisfy the input-table invariant. + #[test] + fn simplify_preserves_invariant_with_mixed_predicated_and_unpredicated_scans() { + let mut builder = PlanBuilder::new(evm_like_tables()); + // Predicate-less logs scan. + builder.add_scan("logs"); + // Predicated logs scan with a join. + builder + .add_scan("logs") + .with_predicate(col_gt_eq("block_number", 0u64)) + .join( + "transactions", + vec!["block_number", "transaction_index"], + vec!["block_number", "transaction_index"], + ); + + let plan = builder.build(); + + // The surviving join must still be attached to a logs scan that owns + // it; the surviving logs scan with a predicate is the natural owner. + let join_idx = plan + .relations + .iter() + .position(|r| r.input_table() == "logs" && r.output_table() == "transactions") + .expect("logs->transactions join must survive"); + let owning_scan = plan + .scans + .iter() + .find(|s| s.relations.contains(&join_idx)) + .expect("some scan must own the join"); + assert_eq!(owning_scan.table, "logs"); + } + + /// Direct guard test: assemble a PlanBuilder whose scan owns a relation + /// with a mismatched input_table and confirm the invariant assertion fires. + /// This is the safety net that turns every future planning code path into + /// an implicit verifier of the input-table contract. + #[test] + #[should_panic(expected = "plan invariant violated")] + fn assert_scan_relation_invariant_panics_on_input_table_mismatch() { + let mut builder = PlanBuilder::new(evm_like_tables()); + // Manually construct a malformed plan: a scan on `transactions` that + // owns a `logs -> transactions` join. This is the exact shape produced + // by the pre-fix simplify() and is what must be rejected. + builder.relations.push(Rel::Join { + input_table: "logs", + input_key: vec!["block_number", "transaction_index"], + output_table: "transactions", + output_key: vec!["block_number", "transaction_index"], + }); + builder.scans.push(Scan { + table: "transactions", + predicate: None, + relations: vec![0], + output: None, + }); + builder.assert_scan_relation_invariant(); + } +} From 98a63cadc4ddc1a7e7ed8a6fc45c38fb04b4c87d Mon Sep 17 00:00:00 2001 From: Dzmitry Kalabuk Date: Wed, 20 May 2026 15:14:17 +0200 Subject: [PATCH 3/3] Add EVM fixtures covering selector shapes affected by simplify() bug Co-Authored-By: Claude Opus 4.7 (1M context) --- .../query.json | 24 +++++++++++++++++++ .../result.json | 3 +++ .../query.json | 19 +++++++++++++++ .../result.json | 3 +++ .../query.json | 24 +++++++++++++++++++ .../result.json | 3 +++ .../query.json | 24 +++++++++++++++++++ .../result.json | 3 +++ .../query.json | 24 +++++++++++++++++++ .../result.json | 3 +++ .../query.json | 24 +++++++++++++++++++ .../result.json | 3 +++ .../query.json | 24 +++++++++++++++++++ .../result.json | 3 +++ 14 files changed, 184 insertions(+) create mode 100644 crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction/query.json create mode 100644 crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction/result.json create mode 100644 crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_logs/query.json create mode 100644 crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_logs/result.json create mode 100644 crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_state_diffs/query.json create mode 100644 crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_state_diffs/result.json create mode 100644 crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_traces/query.json create mode 100644 crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_traces/result.json create mode 100644 crates/query/fixtures/ethereum/queries/state_diffs_no_predicate_with_transaction/query.json create mode 100644 crates/query/fixtures/ethereum/queries/state_diffs_no_predicate_with_transaction/result.json create mode 100644 crates/query/fixtures/ethereum/queries/traces_no_predicate_with_transaction/query.json create mode 100644 crates/query/fixtures/ethereum/queries/traces_no_predicate_with_transaction/result.json create mode 100644 crates/query/fixtures/ethereum/queries/traces_no_predicate_with_transaction_logs/query.json create mode 100644 crates/query/fixtures/ethereum/queries/traces_no_predicate_with_transaction_logs/result.json diff --git a/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction/query.json b/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction/query.json new file mode 100644 index 00000000..d6a52592 --- /dev/null +++ b/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction/query.json @@ -0,0 +1,24 @@ +{ + "type": "evm", + "fromBlock": 17881390, + "toBlock": 17881390, + "logs": [ + { "transaction": true } + ], + "fields": { + "log": { + "logIndex": true, + "transactionIndex": true, + "address": true + }, + "transaction": { + "transactionIndex": true, + "from": true, + "sighash": true + }, + "block": { + "number": true, + "hash": true + } + } +} diff --git a/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction/result.json b/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction/result.json new file mode 100644 index 00000000..81a971e8 --- /dev/null +++ b/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction/result.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f6d416a6e39b0ef749ddf3cc51735cfb5c7369a301dfe09ee61b619366e104f +size 64155 diff --git a/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_logs/query.json b/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_logs/query.json new file mode 100644 index 00000000..9ab9fe89 --- /dev/null +++ b/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_logs/query.json @@ -0,0 +1,19 @@ +{ + "type": "evm", + "fromBlock": 17881390, + "toBlock": 17881390, + "logs": [ + { "transactionLogs": true } + ], + "fields": { + "log": { + "logIndex": true, + "transactionIndex": true, + "address": true + }, + "block": { + "number": true, + "hash": true + } + } +} diff --git a/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_logs/result.json b/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_logs/result.json new file mode 100644 index 00000000..3f08ad54 --- /dev/null +++ b/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_logs/result.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52480765f4f0036f661515636ffe0eff6b2d256a435d92d7d0a952f75a135f02 +size 51531 diff --git a/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_state_diffs/query.json b/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_state_diffs/query.json new file mode 100644 index 00000000..9f87e09b --- /dev/null +++ b/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_state_diffs/query.json @@ -0,0 +1,24 @@ +{ + "type": "evm", + "fromBlock": 17881390, + "toBlock": 17881390, + "logs": [ + { "transactionStateDiffs": true } + ], + "fields": { + "log": { + "logIndex": true, + "transactionIndex": true, + "address": true + }, + "stateDiff": { + "transactionIndex": true, + "address": true, + "key": true + }, + "block": { + "number": true, + "hash": true + } + } +} diff --git a/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_state_diffs/result.json b/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_state_diffs/result.json new file mode 100644 index 00000000..19a6fc8c --- /dev/null +++ b/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_state_diffs/result.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b426457e4bbad63fbdd85134d09f284c68764b6a20c8acce4d7968e378dcb503 +size 172503 diff --git a/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_traces/query.json b/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_traces/query.json new file mode 100644 index 00000000..50497531 --- /dev/null +++ b/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_traces/query.json @@ -0,0 +1,24 @@ +{ + "type": "evm", + "fromBlock": 17881390, + "toBlock": 17881390, + "logs": [ + { "transactionTraces": true } + ], + "fields": { + "log": { + "logIndex": true, + "transactionIndex": true, + "address": true + }, + "trace": { + "transactionIndex": true, + "traceAddress": true, + "type": true + }, + "block": { + "number": true, + "hash": true + } + } +} diff --git a/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_traces/result.json b/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_traces/result.json new file mode 100644 index 00000000..ca7af407 --- /dev/null +++ b/crates/query/fixtures/ethereum/queries/logs_no_predicate_with_transaction_traces/result.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f01c7555b39948032669765da41d3ae94c388177f10c36afa9508e58a437d01f +size 142025 diff --git a/crates/query/fixtures/ethereum/queries/state_diffs_no_predicate_with_transaction/query.json b/crates/query/fixtures/ethereum/queries/state_diffs_no_predicate_with_transaction/query.json new file mode 100644 index 00000000..9c9c4ed6 --- /dev/null +++ b/crates/query/fixtures/ethereum/queries/state_diffs_no_predicate_with_transaction/query.json @@ -0,0 +1,24 @@ +{ + "type": "evm", + "fromBlock": 17881390, + "toBlock": 17881390, + "stateDiffs": [ + { "transaction": true } + ], + "fields": { + "stateDiff": { + "transactionIndex": true, + "address": true, + "key": true + }, + "transaction": { + "transactionIndex": true, + "from": true, + "sighash": true + }, + "block": { + "number": true, + "hash": true + } + } +} diff --git a/crates/query/fixtures/ethereum/queries/state_diffs_no_predicate_with_transaction/result.json b/crates/query/fixtures/ethereum/queries/state_diffs_no_predicate_with_transaction/result.json new file mode 100644 index 00000000..c0e6eb47 --- /dev/null +++ b/crates/query/fixtures/ethereum/queries/state_diffs_no_predicate_with_transaction/result.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ebb00d495cbdbc7cd3ec2ca10a601cadc1a033a6a026d61bb195c615237c8c7b +size 161745 diff --git a/crates/query/fixtures/ethereum/queries/traces_no_predicate_with_transaction/query.json b/crates/query/fixtures/ethereum/queries/traces_no_predicate_with_transaction/query.json new file mode 100644 index 00000000..fe3746fe --- /dev/null +++ b/crates/query/fixtures/ethereum/queries/traces_no_predicate_with_transaction/query.json @@ -0,0 +1,24 @@ +{ + "type": "evm", + "fromBlock": 17881390, + "toBlock": 17881390, + "traces": [ + { "transaction": true } + ], + "fields": { + "trace": { + "transactionIndex": true, + "traceAddress": true, + "type": true + }, + "transaction": { + "transactionIndex": true, + "from": true, + "sighash": true + }, + "block": { + "number": true, + "hash": true + } + } +} diff --git a/crates/query/fixtures/ethereum/queries/traces_no_predicate_with_transaction/result.json b/crates/query/fixtures/ethereum/queries/traces_no_predicate_with_transaction/result.json new file mode 100644 index 00000000..abf81062 --- /dev/null +++ b/crates/query/fixtures/ethereum/queries/traces_no_predicate_with_transaction/result.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a045614b7fe336cc7825c4253a7a33897c23d91194741828b617214e945900d2 +size 113578 diff --git a/crates/query/fixtures/ethereum/queries/traces_no_predicate_with_transaction_logs/query.json b/crates/query/fixtures/ethereum/queries/traces_no_predicate_with_transaction_logs/query.json new file mode 100644 index 00000000..aaecc11d --- /dev/null +++ b/crates/query/fixtures/ethereum/queries/traces_no_predicate_with_transaction_logs/query.json @@ -0,0 +1,24 @@ +{ + "type": "evm", + "fromBlock": 17881390, + "toBlock": 17881390, + "traces": [ + { "transactionLogs": true } + ], + "fields": { + "trace": { + "transactionIndex": true, + "traceAddress": true, + "type": true + }, + "log": { + "logIndex": true, + "transactionIndex": true, + "address": true + }, + "block": { + "number": true, + "hash": true + } + } +} diff --git a/crates/query/fixtures/ethereum/queries/traces_no_predicate_with_transaction_logs/result.json b/crates/query/fixtures/ethereum/queries/traces_no_predicate_with_transaction_logs/result.json new file mode 100644 index 00000000..9e508f2b --- /dev/null +++ b/crates/query/fixtures/ethereum/queries/traces_no_predicate_with_transaction_logs/result.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7c148063925122664cfa2a76e3e6ec20906f6fdaa2e714d569db73a74f2aa9ed +size 147033