Skip to main content

linkrs_query/executor/streaming/operators/blocking/
materialize_operator.rs

1use std::sync::Arc;
2
3use linkrs_core::columnar::MaterializedBatch;
4use linkrs_core::error::QueryError;
5use linkrs_core::types::expr::Expression;
6use linkrs_core::value::NullType;
7use linkrs_core::Value;
8
9use crate::executor::base::{MemoryBudget, MemoryTracker};
10use crate::executor::expression::evaluator::ExpressionEvaluator;
11use crate::executor::streaming::chunk::DataChunk;
12use crate::executor::streaming::executor::{StreamingExecutor, ValueRowContext};
13use crate::executor::streaming::spill::{
14    finalize_partitions_with_runtime, HashPartitionConfig, HashPartitionSpiller, RunReader,
15    SpillManager, SpilledRun,
16};
17
18use super::helpers::{emit_batch_slice, BlockingContext};
19use super::materialize::{
20    project_key, DataCollectState, DistinctState, MaterializeState, RollUpApplyState,
21};
22
23pub(super) fn open_distinct(state: &mut Option<DistinctState>) {
24    *state = Some(DistinctState {
25        seen_rows: std::collections::HashSet::new(),
26        key_cols: Vec::new(),
27        batch: MaterializedBatch::new(0, 0),
28        emitted_offset: 0,
29        col_names: Vec::new(),
30        input_layout: None,
31        partition_spiller: None,
32        spilled_runs: vec![],
33        current_partition: 0,
34        partition_seen: std::collections::HashSet::new(),
35        has_spilled: false,
36    });
37}
38
39pub(super) fn open_materialize(state: &mut Option<MaterializeState>) {
40    *state = Some(MaterializeState {
41        batch: MaterializedBatch::new(0, 0),
42        emitted_offset: 0,
43        materialized: false,
44        input_layout: None,
45        spilled_runs: Vec::new(),
46        replay_index: 0,
47        replay_reader: None,
48        replay_batch: None,
49        replay_offset: 0,
50        accounted_bytes: 0,
51    });
52}
53
54pub(super) fn open_data_collect(state: &mut Option<DataCollectState>) {
55    *state = Some(DataCollectState {
56        batch: MaterializedBatch::new(0, 0),
57        emitted: false,
58        emitted_offset: 0,
59        input_layout: None,
60        spilled_runs: Vec::new(),
61        replay_index: 0,
62        replay_reader: None,
63        replay_batch: None,
64        replay_offset: 0,
65        accounted_bytes: 0,
66    });
67}
68
69pub(super) fn open_rollup_apply(state: &mut Option<RollUpApplyState>) {
70    *state = Some(RollUpApplyState {
71        batch: MaterializedBatch::new(0, 0),
72        emitted_offset: 0,
73        accumulated: false,
74        spilled_runs: Vec::new(),
75        replay_index: 0,
76        replay_reader: None,
77        replay_batch: None,
78        replay_offset: 0,
79        accounted_bytes: 0,
80    });
81}
82
83/// Drain a full batch to a new spill run, releasing its accounted memory.
84///
85/// Runs append in creation order so replay preserves input order. Empty
86/// batches are skipped (the caller then fails the reserve, surfacing a
87/// genuine single-row-over-budget error).
88fn drain_batch_to_run(
89    batch: &mut MaterializedBatch,
90    accounted: &mut usize,
91    runs: &mut Vec<SpilledRun>,
92    sm: &SpillManager,
93    memory_tracker: &mut MemoryTracker,
94    ctx: Option<&BlockingContext<'_>>,
95) -> Result<(), QueryError> {
96    if batch.is_empty() {
97        return Ok(());
98    }
99    let mut writer = sm.create_run_writer(batch.schema_fingerprint())?;
100    writer.write_batch(batch)?;
101    let run = sm.finalize_run(writer)?;
102    if let Some(ctx) = ctx {
103        if let Some(rt) = ctx.runtime.as_ref() {
104            rt.columnar_stats()
105                .record_spill(run.row_count, run.byte_size);
106        }
107    }
108    runs.push(run);
109    memory_tracker.release(*accounted);
110    *accounted = 0;
111    batch.clear();
112    Ok(())
113}
114
115/// Serve the next chunk from ordered spill runs.
116///
117/// Returns `Ok(None)` when all runs are replayed; the caller then falls
118/// through to its in-memory tail. Exhausted runs are unlinked eagerly.
119fn replay_next_chunk(
120    runs: &[SpilledRun],
121    replay_index: &mut usize,
122    replay_reader: &mut Option<RunReader>,
123    replay_batch: &mut Option<MaterializedBatch>,
124    replay_offset: &mut usize,
125    take: usize,
126    ctx: &BlockingContext<'_>,
127) -> Result<Option<DataChunk>, QueryError> {
128    loop {
129        if *replay_index >= runs.len() {
130            return Ok(None);
131        }
132        let filled = replay_batch
133            .as_ref()
134            .is_some_and(|b| *replay_offset < b.num_rows());
135        if !filled {
136            if replay_reader.is_none() {
137                *replay_reader = Some(RunReader::open(&runs[*replay_index])?);
138            }
139            match replay_reader
140                .as_mut()
141                .expect("replay reader must be present")
142                .read_batch(take)?
143            {
144                Some(b) => {
145                    *replay_batch = Some(b);
146                    *replay_offset = 0;
147                }
148                None => {
149                    let _ = std::fs::remove_file(&runs[*replay_index].path);
150                    *replay_reader = None;
151                    *replay_batch = None;
152                    *replay_offset = 0;
153                    *replay_index += 1;
154                    continue;
155                }
156            }
157        }
158        let (len, chunk) = {
159            let batch = replay_batch.as_ref().expect("replay batch must be filled");
160            let total = batch.num_rows();
161            let len = take.min(total - *replay_offset);
162            (
163                len,
164                DataChunk::slice_from_batch(
165                    batch,
166                    *replay_offset,
167                    len,
168                    Arc::clone(ctx.output_layout),
169                ),
170            )
171        };
172        *replay_offset += len;
173        if let Some(rt) = ctx.runtime.as_ref() {
174            rt.columnar_stats().record_batch_outlet();
175        }
176        return Ok(Some(chunk));
177    }
178}
179
180/// Reserve memory for one row, spilling the batch on pressure.
181///
182/// On breach with a spill manager, the batch drains to a new run and the
183/// reserve is retried once; a still-failing reserve (single row over budget)
184/// and the no-manager case both surface the original budget error.
185fn reserve_or_spill(
186    memory_tracker: &mut MemoryTracker,
187    est: usize,
188    batch: &mut MaterializedBatch,
189    accounted: &mut usize,
190    runs: &mut Vec<SpilledRun>,
191    ctx: &BlockingContext<'_>,
192) -> Result<(), QueryError> {
193    if let Err(e) = memory_tracker.try_reserve(est) {
194        match ctx.runtime.as_ref().and_then(|rt| rt.get_spill_manager()) {
195            Some(sm) => {
196                if batch.is_empty() {
197                    return Err(e);
198                }
199                drain_batch_to_run(batch, accounted, runs, &sm, memory_tracker, Some(ctx))?;
200                memory_tracker.try_reserve(est).map_err(|_| e)?;
201            }
202            None => return Err(e),
203        }
204    }
205    Ok(())
206}
207
208pub(super) fn next_distinct(
209    memory_tracker: &mut MemoryTracker,
210    state: &mut DistinctState,
211    ctx: &BlockingContext<'_>,
212    input: &mut StreamingExecutor,
213) -> Result<Option<DataChunk>, QueryError> {
214    // Output phase: serve slices from the output batch.
215    if state.emitted_offset < state.batch.num_rows() {
216        let total = state.batch.num_rows();
217        let len = 2048.min(total - state.emitted_offset);
218        let chunk = DataChunk::slice_from_batch(
219            &state.batch,
220            state.emitted_offset,
221            len,
222            Arc::clone(ctx.output_layout),
223        );
224        state.emitted_offset += len;
225        if let Some(rt) = ctx.runtime.as_ref() {
226            rt.columnar_stats().record_batch_outlet();
227        }
228        return Ok(Some(chunk));
229    }
230
231    // Replay phase
232    if state.has_spilled && state.partition_spiller.is_none() {
233        while state.current_partition < state.spilled_runs.len() {
234            if let Some(rt) = ctx.runtime.as_ref() {
235                rt.ensure_not_cancelled()?;
236            }
237
238            let run = match &state.spilled_runs[state.current_partition] {
239                Some(r) => r,
240                None => {
241                    state.current_partition += 1;
242                    continue;
243                }
244            };
245
246            let mut reader = crate::executor::streaming::spill::RunReader::open(run)?;
247            state.batch.clear();
248            state.emitted_offset = 0;
249            if !state.col_names.is_empty() {
250                state
251                    .batch
252                    .set_schema_names(Arc::from(state.col_names.clone().into_boxed_slice()));
253            }
254
255            while let Some(row) = reader.read_row()? {
256                let key = project_key(&row, &state.key_cols);
257                if !state.partition_seen.contains(&key) {
258                    memory_tracker.try_reserve_row(&row)?;
259                    state.partition_seen.insert(key);
260                    state.batch.append_row(row);
261                }
262            }
263
264            let _ = std::fs::remove_file(&run.path);
265            state.partition_seen.clear();
266            memory_tracker.reset();
267            state.current_partition += 1;
268
269            if !state.batch.is_empty() {
270                let len = 2048.min(state.batch.num_rows());
271                let chunk = DataChunk::slice_from_batch(
272                    &state.batch,
273                    0,
274                    len,
275                    Arc::clone(ctx.output_layout),
276                );
277                state.emitted_offset = len;
278                if let Some(rt) = ctx.runtime.as_ref() {
279                    rt.columnar_stats().record_batch_outlet();
280                }
281                return Ok(Some(chunk));
282            }
283        }
284        return Ok(None);
285    }
286
287    // Accumulation phase
288    let mut accumulating = true;
289    while accumulating {
290        match input.advance()? {
291            Some(mut chunk) => {
292                chunk.normalize_for_opaque("Distinct");
293                if let Some(rt) = ctx.runtime.as_ref() {
294                    rt.ensure_not_cancelled()?;
295                }
296                if state.col_names.is_empty() {
297                    state.col_names = chunk.col_names();
298                    state.input_layout = Some(chunk.get_layout());
299                }
300                for row in chunk.rows {
301                    let key = project_key(&row, &state.key_cols);
302                    if !state.seen_rows.contains(&key) {
303                        if let Err(e) = memory_tracker.try_reserve_row(&row) {
304                            if let Some(sm) =
305                                ctx.runtime.as_ref().and_then(|rt| rt.get_spill_manager())
306                            {
307                                // Spill drains full rows. Keyed spill would
308                                // lose non-key columns, so only the identity
309                                // projection may take this path.
310                                debug_assert!(
311                                    state.key_cols.is_empty(),
312                                    "keyed distinct spill needs a row store"
313                                );
314                                let config = HashPartitionConfig::default();
315                                let mut spiller = HashPartitionSpiller::new(config, &sm, 0)?;
316
317                                for seen_row in state.seen_rows.drain() {
318                                    spiller.insert_row(&seen_row, &sm)?;
319                                    memory_tracker
320                                        .release(MemoryBudget::estimate_row_memory(&seen_row));
321                                }
322
323                                spiller.insert_row(&row, &sm)?;
324                                memory_tracker.release(MemoryBudget::estimate_row_memory(&row));
325
326                                state.partition_spiller = Some(spiller);
327                                state.has_spilled = true;
328                                accumulating = false;
329                                break;
330                            } else {
331                                return Err(e);
332                            }
333                        }
334                        state.seen_rows.insert(key);
335                    }
336                }
337            }
338            None => {
339                accumulating = false;
340            }
341        }
342    }
343
344    // Spill consumption phase
345    if let Some(ref mut spiller) = state.partition_spiller {
346        while let Some(mut chunk) = input.advance()? {
347            chunk.normalize_for_opaque("Distinct");
348            if let Some(rt) = ctx.runtime.as_ref() {
349                rt.ensure_not_cancelled()?;
350            }
351            let sm = ctx
352                .runtime
353                .as_ref()
354                .and_then(|rt| rt.get_spill_manager())
355                .ok_or_else(|| QueryError::execution("Spill manager not available".to_string()))?;
356            for row in chunk.rows {
357                spiller.insert_row(&row, &sm)?;
358            }
359        }
360
361        let runs = finalize_partitions_with_runtime(
362            state.partition_spiller.take().unwrap(),
363            ctx.runtime.as_ref(),
364        )?;
365        state.spilled_runs = runs;
366        state.current_partition = 0;
367        state.partition_seen.clear();
368
369        return Ok(None);
370    }
371
372    // In-memory output phase: drain keys into the output batch, then emit.
373    // With the identity projection the drained keys are the full rows.
374    debug_assert!(
375        state.key_cols.is_empty(),
376        "keyed distinct output needs a row store"
377    );
378    state.batch.clear();
379    state.emitted_offset = 0;
380    if !state.col_names.is_empty() {
381        state
382            .batch
383            .set_schema_names(Arc::from(state.col_names.clone().into_boxed_slice()));
384    }
385    for key_row in state.seen_rows.drain() {
386        state.batch.append_row(key_row);
387    }
388
389    if state.batch.is_empty() {
390        return Ok(None);
391    }
392    let len = 2048.min(state.batch.num_rows());
393    let chunk = DataChunk::slice_from_batch(&state.batch, 0, len, Arc::clone(ctx.output_layout));
394    state.emitted_offset = len;
395    if let Some(rt) = ctx.runtime.as_ref() {
396        rt.columnar_stats().record_batch_outlet();
397    }
398    Ok(Some(chunk))
399}
400
401pub(super) fn next_materialize(
402    memory_tracker: &mut MemoryTracker,
403    state: &mut MaterializeState,
404    ctx: &BlockingContext<'_>,
405    input: &mut StreamingExecutor,
406) -> Result<Option<DataChunk>, QueryError> {
407    if !state.materialized {
408        while let Some(mut chunk) = input.advance()? {
409            chunk.normalize_for_opaque("Materialize");
410            if let Some(rt) = ctx.runtime.as_ref() {
411                rt.ensure_not_cancelled()?;
412            }
413            if state.input_layout.is_none() {
414                state.input_layout = Some(chunk.get_layout());
415                state
416                    .batch
417                    .set_schema_names(Arc::from(chunk.col_names().into_boxed_slice()));
418            }
419            for row in chunk.rows {
420                let est = MemoryBudget::estimate_row_memory(&row);
421                reserve_or_spill(
422                    memory_tracker,
423                    est,
424                    &mut state.batch,
425                    &mut state.accounted_bytes,
426                    &mut state.spilled_runs,
427                    ctx,
428                )?;
429                state.accounted_bytes += est;
430                state.batch.append_row(row);
431            }
432        }
433
434        state.materialized = true;
435        state.emitted_offset = 0;
436        state.replay_index = 0;
437    }
438
439    // Spilled runs replay first (creation order = input order).
440    if let Some(chunk) = replay_next_chunk(
441        &state.spilled_runs,
442        &mut state.replay_index,
443        &mut state.replay_reader,
444        &mut state.replay_batch,
445        &mut state.replay_offset,
446        ctx.config.chunk_size,
447        ctx,
448    )? {
449        return Ok(Some(chunk));
450    }
451
452    // In-memory tail.
453    if state.emitted_offset < state.batch.num_rows() {
454        return Ok(Some(emit_batch_slice(
455            &state.batch,
456            &mut state.emitted_offset,
457            ctx,
458        )));
459    }
460    Ok(None)
461}
462
463pub(super) fn next_data_collect(
464    memory_tracker: &mut MemoryTracker,
465    state: &mut DataCollectState,
466    ctx: &BlockingContext<'_>,
467    input: &mut StreamingExecutor,
468) -> Result<Option<DataChunk>, QueryError> {
469    if state.emitted {
470        return Ok(None);
471    }
472
473    while let Some(mut chunk) = input.advance()? {
474        chunk.normalize_for_opaque("DataCollect");
475        if let Some(rt) = ctx.runtime.as_ref() {
476            rt.ensure_not_cancelled()?;
477        }
478        if state.input_layout.is_none() {
479            state.input_layout = Some(chunk.get_layout());
480            state
481                .batch
482                .set_schema_names(Arc::from(chunk.col_names().into_boxed_slice()));
483        }
484        for row in chunk.rows {
485            let est = MemoryBudget::estimate_row_memory(&row);
486            reserve_or_spill(
487                memory_tracker,
488                est,
489                &mut state.batch,
490                &mut state.accounted_bytes,
491                &mut state.spilled_runs,
492                ctx,
493            )?;
494            state.accounted_bytes += est;
495            state.batch.append_row(row);
496        }
497    }
498
499    // Fast path (no spill): single chunk, the established contract.
500    if state.spilled_runs.is_empty() {
501        if !state.batch.is_empty() {
502            state.emitted = true;
503            if let Some(rt) = ctx.runtime.as_ref() {
504                rt.columnar_stats().record_batch_outlet();
505            }
506            return Ok(Some(DataChunk::from_batch(
507                &state.batch,
508                Arc::clone(ctx.output_layout),
509            )));
510        }
511        return Ok(None);
512    }
513
514    // Spill path: stream runs first, then the in-memory tail.
515    if let Some(chunk) = replay_next_chunk(
516        &state.spilled_runs,
517        &mut state.replay_index,
518        &mut state.replay_reader,
519        &mut state.replay_batch,
520        &mut state.replay_offset,
521        ctx.config.chunk_size,
522        ctx,
523    )? {
524        return Ok(Some(chunk));
525    }
526    if state.emitted_offset < state.batch.num_rows() {
527        return Ok(Some(emit_batch_slice(
528            &state.batch,
529            &mut state.emitted_offset,
530            ctx,
531        )));
532    }
533    state.emitted = true;
534    Ok(None)
535}
536
537pub(super) fn next_rollup_apply(
538    rollup_expressions: &[Expression],
539    memory_tracker: &mut MemoryTracker,
540    state: &mut RollUpApplyState,
541    ctx: &BlockingContext<'_>,
542    input: &mut StreamingExecutor,
543) -> Result<Option<DataChunk>, QueryError> {
544    loop {
545        // Spilled runs replay first (creation order = input order).
546        if let Some(chunk) = replay_next_chunk(
547            &state.spilled_runs,
548            &mut state.replay_index,
549            &mut state.replay_reader,
550            &mut state.replay_batch,
551            &mut state.replay_offset,
552            ctx.config.chunk_size,
553            ctx,
554        )? {
555            return Ok(Some(chunk));
556        }
557        // In-memory tail.
558        if state.emitted_offset < state.batch.num_rows() {
559            return Ok(Some(emit_batch_slice(
560                &state.batch,
561                &mut state.emitted_offset,
562                ctx,
563            )));
564        }
565        if state.accumulated {
566            return Ok(None);
567        }
568
569        let mut col_names: Vec<String> = Vec::new();
570        let mut schema_names_set = false;
571        while let Some(mut chunk) = input.advance()? {
572            chunk.normalize_for_opaque("RollUpApply");
573            if let Some(rt) = ctx.runtime.as_ref() {
574                rt.ensure_not_cancelled()?;
575            }
576            if col_names.is_empty() {
577                col_names = chunk.col_names();
578            }
579            if !schema_names_set {
580                state
581                    .batch
582                    .set_schema_names(Arc::from(chunk.col_names().into_boxed_slice()));
583                schema_names_set = true;
584            }
585            for row in chunk.rows {
586                let mut ctx_eval = ValueRowContext::from_names(row.clone(), col_names.clone());
587                let mut aggregated = row.clone();
588                for expr in rollup_expressions.iter() {
589                    match ExpressionEvaluator::evaluate(expr, &mut ctx_eval) {
590                        Ok(val) => aggregated.push(val),
591                        Err(_) => aggregated.push(Value::Null(NullType::Null)),
592                    }
593                }
594                let est = MemoryBudget::estimate_row_memory(&aggregated);
595                reserve_or_spill(
596                    memory_tracker,
597                    est,
598                    &mut state.batch,
599                    &mut state.accounted_bytes,
600                    &mut state.spilled_runs,
601                    ctx,
602                )?;
603                state.accounted_bytes += est;
604                state.batch.append_row(aggregated);
605            }
606        }
607        state.accumulated = true;
608    }
609}
610
611pub(super) fn close_distinct(state: &mut Option<DistinctState>) {
612    if let Some(ref s) = state {
613        for r in s.spilled_runs.iter().flatten() {
614            let _ = std::fs::remove_file(&r.path);
615        }
616    }
617    *state = None;
618}
619
620pub(super) fn close_materialize(state: &mut Option<MaterializeState>) {
621    if let Some(ref s) = state {
622        for r in &s.spilled_runs {
623            let _ = std::fs::remove_file(&r.path);
624        }
625    }
626    *state = None;
627}
628
629pub(super) fn close_data_collect(state: &mut Option<DataCollectState>) {
630    if let Some(ref s) = state {
631        for r in &s.spilled_runs {
632            let _ = std::fs::remove_file(&r.path);
633        }
634    }
635    *state = None;
636}
637
638pub(super) fn close_rollup_apply(state: &mut Option<RollUpApplyState>) {
639    if let Some(ref s) = state {
640        for r in &s.spilled_runs {
641            let _ = std::fs::remove_file(&r.path);
642        }
643    }
644    *state = None;
645}
646
647pub(super) fn spill_distinct(
648    state: &mut DistinctState,
649    memory_tracker: &mut MemoryTracker,
650    sm: &SpillManager,
651) -> Result<(), QueryError> {
652    if !state.seen_rows.is_empty() {
653        let config = HashPartitionConfig::default();
654        let mut spiller = HashPartitionSpiller::new(config, sm, 0)?;
655        for row in state.seen_rows.drain() {
656            spiller.insert_row(&row, sm)?;
657            memory_tracker.release(MemoryBudget::estimate_row_memory(&row));
658        }
659        state.partition_spiller = Some(spiller);
660        state.has_spilled = true;
661    }
662    Ok(())
663}
664
665pub(super) fn spill_materialize(
666    state: &mut MaterializeState,
667    sm: &SpillManager,
668    memory_tracker: &mut MemoryTracker,
669) -> Result<(), QueryError> {
670    drain_batch_to_run(
671        &mut state.batch,
672        &mut state.accounted_bytes,
673        &mut state.spilled_runs,
674        sm,
675        memory_tracker,
676        None,
677    )
678}
679
680pub(super) fn spill_data_collect(
681    state: &mut DataCollectState,
682    sm: &SpillManager,
683    memory_tracker: &mut MemoryTracker,
684) -> Result<(), QueryError> {
685    drain_batch_to_run(
686        &mut state.batch,
687        &mut state.accounted_bytes,
688        &mut state.spilled_runs,
689        sm,
690        memory_tracker,
691        None,
692    )
693}
694
695pub(super) fn spill_rollup_apply(
696    state: &mut RollUpApplyState,
697    sm: &SpillManager,
698    memory_tracker: &mut MemoryTracker,
699) -> Result<(), QueryError> {
700    drain_batch_to_run(
701        &mut state.batch,
702        &mut state.accounted_bytes,
703        &mut state.spilled_runs,
704        sm,
705        memory_tracker,
706        None,
707    )
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use crate::executor::base::MemoryBudget;
714    use crate::executor::streaming::slot::SlotLayout;
715    use crate::executor::streaming::spill::SpillConfig;
716
717    fn test_ctx<'a>(
718        output_layout: &'a Arc<SlotLayout>,
719        config: &'a crate::executor::streaming::operators::source_operator::OperatorConfig,
720    ) -> BlockingContext<'a> {
721        BlockingContext {
722            runtime: &None,
723            output_layout,
724            config,
725        }
726    }
727
728    #[test]
729    fn spill_replay_preserves_input_order() {
730        let sm = SpillManager::new(SpillConfig::default(), 9101).expect("spill manager");
731        let mut tracker = MemoryTracker::new(MemoryBudget::default_budget());
732        let layout = Arc::new(SlotLayout::from_names(&["v".to_string()]));
733        let config =
734            crate::executor::streaming::operators::source_operator::OperatorConfig::default();
735        let ctx = test_ctx(&layout, &config);
736
737        let rows: Vec<Vec<Value>> = (0..10).map(|i| vec![Value::BigInt(i)]).collect();
738        // Two ordered runs: [0..6) then [6..8); tail batch holds [8..10).
739        let mut first = MaterializedBatch::from_rows(rows[0..6].to_vec());
740        let mut accounted = 0usize;
741        let mut runs = Vec::new();
742        drain_batch_to_run(
743            &mut first,
744            &mut accounted,
745            &mut runs,
746            &sm,
747            &mut tracker,
748            Some(&ctx),
749        )
750        .expect("drain first");
751        assert!(first.is_empty());
752        let mut second = MaterializedBatch::from_rows(rows[6..8].to_vec());
753        drain_batch_to_run(
754            &mut second,
755            &mut accounted,
756            &mut runs,
757            &sm,
758            &mut tracker,
759            Some(&ctx),
760        )
761        .expect("drain second");
762        assert_eq!(runs.len(), 2);
763
764        let tail = MaterializedBatch::from_rows(rows[8..10].to_vec());
765        let mut replay_index = 0usize;
766        let mut replay_reader = None;
767        let mut replay_batch = None;
768        let mut replay_offset = 0usize;
769        let mut out = Vec::new();
770        while let Some(chunk) = replay_next_chunk(
771            &runs,
772            &mut replay_index,
773            &mut replay_reader,
774            &mut replay_batch,
775            &mut replay_offset,
776            3,
777            &ctx,
778        )
779        .expect("replay")
780        {
781            out.extend(chunk.rows);
782        }
783        out.extend(tail.to_rows());
784        assert_eq!(out, rows);
785        // Exhausted runs are unlinked eagerly.
786        assert!(!runs[0].path.exists());
787        assert!(!runs[1].path.exists());
788    }
789
790    #[test]
791    fn reserve_or_spill_errors_without_manager() {
792        let mut tracker = MemoryTracker::new(MemoryBudget::new(1));
793        let layout = Arc::new(SlotLayout::from_names(&["v".to_string()]));
794        let config =
795            crate::executor::streaming::operators::source_operator::OperatorConfig::default();
796        let ctx = test_ctx(&layout, &config);
797        let mut batch = MaterializedBatch::new(0, 0);
798        let mut accounted = 0usize;
799        let mut runs = Vec::new();
800        let row = vec![Value::string("way too large for a 1-byte budget")];
801        let est = MemoryBudget::estimate_row_memory(&row);
802        assert!(reserve_or_spill(
803            &mut tracker,
804            est,
805            &mut batch,
806            &mut accounted,
807            &mut runs,
808            &ctx
809        )
810        .is_err());
811        assert!(runs.is_empty());
812    }
813}