Skip to main content

radixdb_executor/operators/
count_integer_antijoin.rs

1//! Count-only integer anti-join.
2//!
3//! This operator implements the common integrity-check shape
4//! `COUNT(*) FROM outer LEFT JOIN inner ... WHERE inner.not_null IS NULL`
5//! without constructing joined rows. The outer side remains a narrow stream.
6//! Integer primary keys use bounded visibility probes; other integer keys use
7//! a compact key set built from an exact one-column scan of the inner side.
8
9use rustc_hash::FxHashSet;
10
11use crate::context::ExecutionContext;
12use crate::operator::{ColumnInfo, Operator, RowRef};
13use radixdb_core::{Error, Result, Row, Value};
14use radixdb_storage::traits::Table;
15
16/// A full anti-count has no early-return latency requirement. Use a larger
17/// metadata batch than the interactive semi-join so the segmented table can
18/// reuse one publication guard and cold snapshot across many probes.
19const DEFAULT_INTEGER_ANTIJOIN_PK_BATCH_SIZE: usize = 65_536;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum IntegerAntiJoinLookup {
23    PrimaryKey,
24    Column(usize),
25}
26
27pub struct CountIntegerAntiJoinOperator {
28    outer: Box<dyn Operator>,
29    inner: Box<dyn Table>,
30    outer_key_index: usize,
31    lookup: IntegerAntiJoinLookup,
32    batch_size: usize,
33    schema: Vec<ColumnInfo>,
34    result: Option<Row>,
35    context: Option<ExecutionContext>,
36    opened: bool,
37}
38
39impl CountIntegerAntiJoinOperator {
40    pub fn new(
41        outer: Box<dyn Operator>,
42        inner: Box<dyn Table>,
43        outer_key_index: usize,
44        lookup: IntegerAntiJoinLookup,
45    ) -> Self {
46        Self {
47            outer,
48            inner,
49            outer_key_index,
50            lookup,
51            batch_size: DEFAULT_INTEGER_ANTIJOIN_PK_BATCH_SIZE,
52            schema: vec![ColumnInfo::new("count")],
53            result: None,
54            context: None,
55            opened: false,
56        }
57    }
58
59    pub fn with_context(mut self, context: &ExecutionContext) -> Self {
60        self.context = Some(context.clone());
61        self
62    }
63
64    fn check_cancelled(&self) -> Result<()> {
65        if let Some(context) = &self.context {
66            context.check_cancelled()?;
67        }
68        Ok(())
69    }
70
71    fn count_with_primary_key_probe(&mut self) -> Result<u64> {
72        let mut keys = Vec::with_capacity(self.batch_size);
73        let mut matches = Vec::with_capacity(self.batch_size);
74        let mut unmatched = 0_u64;
75        let mut outer_rows = 0_u64;
76        let mut outer_key_rows = 0_u64;
77
78        while let Some(row) = self.outer.next()? {
79            outer_rows = outer_rows.saturating_add(1);
80            if outer_rows.is_multiple_of(256) {
81                self.check_cancelled()?;
82            }
83            match row.get(self.outer_key_index) {
84                Some(Value::Integer(key)) => {
85                    outer_key_rows = outer_key_rows.saturating_add(1);
86                    keys.push(*key);
87                    if keys.len() == self.batch_size {
88                        unmatched =
89                            unmatched.saturating_add(self.flush_pk_batch(&mut keys, &mut matches)?);
90                    }
91                }
92                Some(value) if value.is_null() => {
93                    // SQL equality never matches NULL, so the preserved outer
94                    // row belongs to the anti result.
95                    unmatched = unmatched.saturating_add(1);
96                }
97                Some(_) => {
98                    return Err(Error::internal(
99                        "count integer anti-join received a non-integer outer key",
100                    ));
101                }
102                None => {
103                    return Err(Error::internal(
104                        "count integer anti-join outer row omitted its key",
105                    ));
106                }
107            }
108        }
109        unmatched = unmatched.saturating_add(self.flush_pk_batch(&mut keys, &mut matches)?);
110        radixdb_storage::instrumentation::record_join_outer_rows(outer_rows, outer_key_rows);
111        Ok(unmatched)
112    }
113
114    fn flush_pk_batch(&self, keys: &mut Vec<i64>, matches: &mut Vec<bool>) -> Result<u64> {
115        if keys.is_empty() {
116            return Ok(0);
117        }
118        matches.clear();
119        matches.resize(keys.len(), false);
120        let hits = self.inner.probe_visible_row_ids(keys, matches)?;
121        radixdb_storage::instrumentation::record_join_pk_probe(keys.len() as u64, hits as u64, 0);
122        let unmatched = keys.len().saturating_sub(hits) as u64;
123        keys.clear();
124        Ok(unmatched)
125    }
126
127    fn count_with_integer_key_set(&mut self, inner_key_index: usize) -> Result<u64> {
128        let mut scanner = self.inner.scan_exact_projection(&[inner_key_index], None)?;
129        let mut inner_keys = FxHashSet::default();
130        let mut inner_rows = 0_u64;
131        while scanner.next() {
132            inner_rows = inner_rows.saturating_add(1);
133            if inner_rows.is_multiple_of(256) {
134                self.check_cancelled()?;
135            }
136            match scanner.row().get(0) {
137                Some(Value::Integer(key)) => {
138                    inner_keys.insert(*key);
139                }
140                Some(value) if value.is_null() => {}
141                Some(_) => {
142                    let _ = scanner.close();
143                    return Err(Error::internal(
144                        "count integer anti-join received a non-integer inner key",
145                    ));
146                }
147                None => {
148                    let _ = scanner.close();
149                    return Err(Error::internal(
150                        "count integer anti-join inner row omitted its key",
151                    ));
152                }
153            }
154        }
155        let scan_error = scanner.err().cloned();
156        let close_result = scanner.close();
157        if let Some(error) = scan_error {
158            return Err(error);
159        }
160        close_result?;
161
162        let mut unmatched = 0_u64;
163        let mut outer_rows = 0_u64;
164        let mut outer_key_rows = 0_u64;
165        while let Some(row) = self.outer.next()? {
166            outer_rows = outer_rows.saturating_add(1);
167            if outer_rows.is_multiple_of(256) {
168                self.check_cancelled()?;
169            }
170            match row.get(self.outer_key_index) {
171                Some(Value::Integer(key)) => {
172                    outer_key_rows = outer_key_rows.saturating_add(1);
173                    if !inner_keys.contains(key) {
174                        unmatched = unmatched.saturating_add(1);
175                    }
176                }
177                Some(value) if value.is_null() => {
178                    unmatched = unmatched.saturating_add(1);
179                }
180                Some(_) => {
181                    return Err(Error::internal(
182                        "count integer anti-join received a non-integer outer key",
183                    ));
184                }
185                None => {
186                    return Err(Error::internal(
187                        "count integer anti-join outer row omitted its key",
188                    ));
189                }
190            }
191        }
192        radixdb_storage::instrumentation::record_join_outer_rows(outer_rows, outer_key_rows);
193        Ok(unmatched)
194    }
195}
196
197impl Operator for CountIntegerAntiJoinOperator {
198    fn open(&mut self) -> Result<()> {
199        if let Err(error) = self.outer.open() {
200            let _ = self.outer.close();
201            return Err(error);
202        }
203        self.check_cancelled()?;
204        let computation = match self.lookup {
205            IntegerAntiJoinLookup::PrimaryKey => self.count_with_primary_key_probe(),
206            IntegerAntiJoinLookup::Column(index) => self.count_with_integer_key_set(index),
207        };
208        let count = match computation {
209            Ok(count) => count,
210            Err(error) => {
211                let _ = self.outer.close();
212                return Err(error);
213            }
214        };
215        self.result = Some(Row::from_values(vec![Value::Integer(
216            i64::try_from(count).unwrap_or(i64::MAX),
217        )]));
218        self.opened = true;
219        Ok(())
220    }
221
222    fn next(&mut self) -> Result<Option<RowRef>> {
223        if !self.opened {
224            return Err(Error::internal(
225                "CountIntegerAntiJoinOperator::next called before open",
226            ));
227        }
228        Ok(self.result.take().map(RowRef::Owned))
229    }
230
231    fn close(&mut self) -> Result<()> {
232        self.outer.close()
233    }
234
235    fn schema(&self) -> &[ColumnInfo] {
236        &self.schema
237    }
238
239    fn estimated_rows(&self) -> Option<usize> {
240        Some(1)
241    }
242
243    fn name(&self) -> &str {
244        "CountIntegerAntiJoin"
245    }
246}