1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
//! Streaming SELECT producer (`execute_streaming_background`) — issue #1578 split.
//!
//! Relocated verbatim from `execute.rs` (epic #1116 file-size split) so the
//! materializing runner and the streaming producer live in separate files. As a
//! child module of `select_executor` this reaches `mod.rs`'s private items
//! directly; logic, ordering, and error handling are unchanged.
use super::{
apply_forcing, build_row_from_scan_cached, classify_partition_lookup, evaluate_predicates,
honest_targeted_path, partition_key_digest, point_requires_engaged, sort_rows_by_token,
validate_token_predicates, ForcedPlan, PartitionKeyCache, PartitionLookupOutcome,
};
use super::{
AccessPath, ExecutionStep, FallbackReason, QueryRow, Result, SelectExecutor, StorageEngine,
TableId, TableSchema,
};
use crate::config::ReadPathMode;
use std::sync::Arc;
use tokio::sync::mpsc;
impl SelectExecutor {
/// Background task: Execute streaming scan and send rows through channel
pub(super) async fn execute_streaming_background(
storage: Arc<StorageEngine>,
// Issue #1587 (E5): schema resolved ONCE per query by `execute_streaming`
// and moved into this task — no per-scan-step registry lock + deep clone.
query_schema: Option<Arc<TableSchema>>,
_table_id: TableId,
execution_steps: Vec<ExecutionStep>,
tx: mpsc::Sender<Result<QueryRow>>,
buffer_size: usize,
// Issue #1918: the read-path forcing mode, resolved once by
// `execute_streaming` (so an invalid env value fails the query
// synchronously before this task is spawned).
mode: ReadPathMode,
) -> Result<()> {
// Issue #581: LIMIT/OFFSET must be enforced by the producer in the
// streaming path. The `ExecutionStep::Limit` arm previously only logged a
// message and relied on a consumer that never applied it, so
// `execute_streaming` yielded the full result set regardless of LIMIT.
// Extract the bound up front (steps are ordered with Limit after the scan)
// and stop sending once it is satisfied — mirroring `execute_limit`
// (drain OFFSET, then truncate to `count`) row-by-row so the producer
// stops scanning early.
let limit = execution_steps.iter().find_map(|step| match step {
ExecutionStep::Limit { count, offset } => Some((*count, offset.unwrap_or(0))),
_ => None,
});
let (limit_count, mut offset_remaining) = match limit {
Some((count, offset)) => (Some(count), offset),
None => (None, 0),
};
// A `LIMIT 0` means no rows can ever be sent; return before scanning.
if limit_count == Some(0) {
return Ok(());
}
// Issue #757: PER PARTITION LIMIT caps rows per partition before the
// query-wide LIMIT/OFFSET. The scan yields rows grouped by partition
// key, so we track the current partition and reset the counter at each
// boundary. Issue #1590 (E8): the boundary is compared on the partition
// key's 128-bit `partition_key_digest` (a heap-free hash of the key bytes
// we already hold) as a FAST pre-check, then confirmed by EXACT byte
// equality against the current partition's raw bytes — stored ONCE when
// the boundary advances, never cloned per row. This keeps correctness
// independent of digest collisions (a collision between two DISTINCT
// partitions never shares a counter).
let per_partition_limit = execution_steps.iter().find_map(|step| match step {
ExecutionStep::PerPartitionLimit { count } => Some(*count),
_ => None,
});
let mut current_partition: Option<(u128, Vec<u8>)> = None;
let mut partition_count: u64 = 0;
let mut sent: u64 = 0;
for step in &execution_steps {
match step {
ExecutionStep::SSTableScan {
table,
predicates,
projection,
..
} => {
let schema_opt = query_schema.as_deref();
// FINDING 2 (Issue #955 follow-up): reject a `token(...)` whose
// columns are not the full partition key in declared order
// before scanning (same rule as the materializing path).
validate_token_predicates(predicates, schema_opt)?;
// Issue #949: a fully-constrained `WHERE pk = ?` is served by a
// partition-targeted lookup that prunes SSTables via bloom/BTI,
// instead of streaming a scan over every SSTable. The resulting
// rows are sent through the same per-row pipeline below
// (predicates, PER PARTITION LIMIT, OFFSET, LIMIT). Note
// `scan_partition` reconciles across SSTable generations like the
// materializing `scan()` (last-write-wins + tombstone shadowing),
// which is the authoritative read semantics; it does not merely
// mirror `scan_stream`'s per-key merge.
// Issue #1918: the single forcing gate wraps the classifier
// outcome. Forced `full` records the distinct `ForcedFullScan`
// reason and skips the targeted branches (`lookup = None`) so
// the shared full-scan streaming code below runs; `point` fails
// closed on a classification `Fallback` inside `apply_forcing`.
let outcome = classify_partition_lookup(predicates, schema_opt);
let lookup = match apply_forcing(outcome, mode)? {
ForcedPlan::ForceFullScan => {
crate::query::access_path::record(AccessPath::FallbackFullScan {
reason: FallbackReason::ForcedFullScan,
});
None
}
ForcedPlan::Proceed(o) => Some(o),
};
if let Some(PartitionLookupOutcome::Targeted(ref pk_bytes)) = lookup {
// Issue #960: the streaming analogue of the materializing
// partition-targeted lookup. Epic #951 (honest paths): the
// `tombstones` build's `scan_partition` is a full-scan +
// retain with NO prune, reported via `engaged == false`; only
// claim `StreamingPartitionLookup` when it really pruned.
let (rows, engaged) =
storage.scan_partition(table, pk_bytes, schema_opt).await?;
// Issue #1918: `point` fails closed on the tombstones-build
// no-prune rather than silently full-scanning.
point_requires_engaged(
mode,
engaged,
FallbackReason::TombstonesBuildNoPrune,
)?;
crate::query::access_path::record(honest_targeted_path(
AccessPath::StreamingPartitionLookup,
engaged,
));
// Issue #1817: hoist the partition-key decode across a
// partition's rows (a lookup returns one partition's rows).
let mut pk_cache = PartitionKeyCache::default();
for (key, value) in rows {
let part_sig =
per_partition_limit.map(|_| partition_key_digest(&key.0));
let Some(row) = build_row_from_scan_cached(
key,
value,
projection,
schema_opt,
&mut pk_cache,
) else {
continue;
};
if !evaluate_predicates(&row, predicates)? {
continue;
}
if let (Some(cap), Some(sig)) = (per_partition_limit, part_sig) {
// Fast digest pre-check, then EXACT byte confirm so a
// digest collision between DISTINCT partitions never
// shares a counter (issue #1590).
let same = matches!(
¤t_partition,
Some((d, bytes))
if *d == sig && bytes.as_slice() == row.key.as_bytes()
);
if !same {
// Clone the key bytes ONCE per boundary, not per row.
current_partition = Some((sig, row.key.as_bytes().to_vec()));
partition_count = 0;
}
if partition_count >= cap {
continue;
}
partition_count += 1;
}
if offset_remaining > 0 {
offset_remaining -= 1;
continue;
}
if tx.send(Ok(row)).await.is_err() {
return Ok(());
}
sent += 1;
if let Some(count) = limit_count {
if sent >= count {
return Ok(());
}
}
}
// This SSTableScan step is fully served by the lookup.
continue;
}
// Issue #955: `WHERE pk IN (...)` over the complete key is the
// union of N partition-targeted lookups. Gather them, sort by
// token to match full-scan order, then drive the same per-row
// pipeline (predicates, PER PARTITION LIMIT, OFFSET, LIMIT).
if let Some(PartitionLookupOutcome::MultiTargeted(ref pk_keys)) = lookup {
// Epic #951 (honest paths): each lookup reports whether it
// pruned. On the `tombstones` build every call full-scans
// (`engaged == false`); claim `MultiPartitionLookup` only when
// the lookups actually pruned, else report the honest fallback.
let mut combined = Vec::new();
let mut all_engaged = true;
for pk_bytes in pk_keys {
let (rows, engaged) =
storage.scan_partition(table, pk_bytes, schema_opt).await?;
all_engaged &= engaged;
combined.extend(rows);
}
// Issue #1918: `point` fails closed when the fan-out did not
// prune (tombstones build).
point_requires_engaged(
mode,
all_engaged,
FallbackReason::TombstonesBuildNoPrune,
)?;
crate::query::access_path::record(honest_targeted_path(
AccessPath::MultiPartitionLookup,
all_engaged,
));
sort_rows_by_token(&mut combined);
// Issue #1817: hoist the partition-key decode across a
// partition's rows (combined is token-sorted, so a
// partition's rows are contiguous).
let mut pk_cache = PartitionKeyCache::default();
for (key, value) in combined {
let part_sig =
per_partition_limit.map(|_| partition_key_digest(&key.0));
let Some(row) = build_row_from_scan_cached(
key,
value,
projection,
schema_opt,
&mut pk_cache,
) else {
continue;
};
if !evaluate_predicates(&row, predicates)? {
continue;
}
if let (Some(cap), Some(sig)) = (per_partition_limit, part_sig) {
// Fast digest pre-check, then EXACT byte confirm so a
// digest collision between DISTINCT partitions never
// shares a counter (issue #1590).
let same = matches!(
¤t_partition,
Some((d, bytes))
if *d == sig && bytes.as_slice() == row.key.as_bytes()
);
if !same {
// Clone the key bytes ONCE per boundary, not per row.
current_partition = Some((sig, row.key.as_bytes().to_vec()));
partition_count = 0;
}
if partition_count >= cap {
continue;
}
partition_count += 1;
}
if offset_remaining > 0 {
offset_remaining -= 1;
continue;
}
if tx.send(Ok(row)).await.is_err() {
return Ok(());
}
sent += 1;
if let Some(count) = limit_count {
if sent >= count {
return Ok(());
}
}
}
// This SSTableScan step is fully served by the lookups.
continue;
}
// Issue #960: the streaming path did not take a targeted
// lookup; report the honest fallback reason. `lookup` is the
// `Fallback` arm here (the `Targeted`/`MultiTargeted` arms
// returned above via `continue`). Issue #1918: forced `full`
// (`lookup == None`) already recorded `ForcedFullScan` above, so
// it falls straight through to the full scan without re-recording.
if let Some(PartitionLookupOutcome::Fallback(reason)) = lookup {
crate::query::access_path::record(AccessPath::FallbackFullScan { reason });
}
// Issue #790: pull rows lazily from a bounded streaming scan
// instead of materializing the full result `Vec`. The reader
// parses one entry at a time into this channel, so live heap
// stays bounded by `buffer_size` rather than O(result rows).
// Issue #1592: consume the BATCHED streaming surface — one
// async wake per batch, not per row. Flattening each batch
// yields the same rows in the same order as `scan_stream`.
let mut scan_stream = storage
.scan_stream_batched(table, None, None, schema_opt, buffer_size)
.await?;
// Issue #1817: hoist the partition-key decode across a
// partition's rows. The cache persists ACROSS batches (a
// partition may straddle a batch boundary), so a partition's
// key is decoded once regardless of batching.
let mut pk_cache = PartitionKeyCache::default();
while let Some(batch) = scan_stream.recv().await {
for (key, value) in batch? {
// Capture the partition-key digest before `key` is moved
// into row construction (only when needed).
let part_sig =
per_partition_limit.map(|_| partition_key_digest(&key.0));
let Some(row) = build_row_from_scan_cached(
key,
value,
projection,
schema_opt,
&mut pk_cache,
) else {
continue;
};
if !evaluate_predicates(&row, predicates)? {
continue;
}
// Apply PER PARTITION LIMIT: cap matching rows per
// partition, before OFFSET/LIMIT (Cassandra semantics).
if let (Some(cap), Some(sig)) = (per_partition_limit, part_sig) {
// Fast digest pre-check, then EXACT byte confirm so a
// digest collision between DISTINCT partitions never
// shares a counter (issue #1590).
let same = matches!(
¤t_partition,
Some((d, bytes))
if *d == sig && bytes.as_slice() == row.key.as_bytes()
);
if !same {
// Clone the key bytes ONCE per boundary, not per row.
current_partition = Some((sig, row.key.as_bytes().to_vec()));
partition_count = 0;
}
if partition_count >= cap {
continue;
}
partition_count += 1;
}
// Apply OFFSET: skip the first `offset_remaining` matches.
if offset_remaining > 0 {
offset_remaining -= 1;
continue;
}
// Send row through channel (with backpressure). Consumer drop ends the scan.
if tx.send(Ok(row)).await.is_err() {
return Ok(());
}
sent += 1;
// Apply LIMIT: stop scanning once `count` rows have been
// sent. Dropping `scan_stream` here signals the producer
// (via a closed channel) to stop parsing early.
if let Some(count) = limit_count {
if sent >= count {
return Ok(());
}
}
}
}
}
ExecutionStep::Limit { .. } | ExecutionStep::PerPartitionLimit { .. } => {
// Enforced inline during the scan above (see the bounds
// extracted before the loop).
}
// Projection and predicate filtering are pushed into SSTableScan above.
ExecutionStep::Project { .. } | ExecutionStep::Filter { .. } => {}
_ => {
// Data-safety (issue #1694): log the step's variant name only,
// never its contents (which carry query literals/values).
tracing::warn!(
"Streaming execution: skipping unsupported step {}",
step.variant_name()
);
}
}
}
Ok(())
}
}