Skip to main content

risingwave_stream/executor/
locality_provider.rs

1// Copyright 2025 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::{BTreeMap, HashMap};
16use std::ops::Bound;
17use std::sync::Arc;
18
19use futures::future::{Either as FutureEither, pending, select};
20use futures::{StreamExt, TryStreamExt, pin_mut};
21use futures_async_stream::try_stream;
22use itertools::Itertools;
23use risingwave_common::array::stream_record::Record;
24use risingwave_common::array::{
25    ArrayImpl, DataChunk, I16Array, I64Array, Op, StreamChunk, StreamChunkBuilder,
26};
27use risingwave_common::catalog::Schema;
28use risingwave_common::hash::{VirtualNode, VnodeBitmapExt};
29use risingwave_common::row::{OwnedRow, Row, RowExt};
30use risingwave_common::types::{DataType, Datum, ScalarImpl, ToOwnedDatum};
31use risingwave_common::util::chunk_coalesce::DataChunkBuilder;
32use risingwave_common::util::sort_util::cmp_datum_iter;
33use risingwave_common_rate_limit::{MonitoredRateLimiter, RateLimit, RateLimiter};
34use risingwave_pb::common::ThrottleType;
35use risingwave_storage::StateStore;
36use risingwave_storage::store::PrefetchOptions;
37
38use crate::common::table::state_table::{FlushedStateTableReader, StateTable};
39use crate::consistency::consistency_panic;
40use crate::executor::backfill::utils::create_builder;
41use crate::executor::prelude::*;
42use crate::task::{CreateMviewProgressReporter, FragmentId};
43
44type Builders = HashMap<VirtualNode, DataChunkBuilder>;
45
46/// Runtime settings for the sort buffer of [`LocalityProviderExecutor`].
47#[derive(Debug, Clone, Copy)]
48pub struct SortBufferSettings {
49    /// Runtime switch. When false, the executor always passes through input directly even if the
50    /// sort buffer table is present in the plan.
51    pub enabled: bool,
52    /// The number of input rows within one epoch after which buffering activates.
53    pub activate_threshold: u64,
54}
55
56/// Op code of a buffered change, stored in the `_rw_op` column of the sort buffer table.
57/// Update ops are normalized to `Insert`/`Delete` on write (see [`Op::normalize_update`]),
58/// and reconstructed as `Update` records by per-key compaction on replay.
59fn op_to_code(op: Op) -> i16 {
60    op.normalize_update().to_i16()
61}
62
63/// Apply one buffered change to the per-key compaction slot, mirroring the semantics of
64/// `ChangeBuffer`: consecutive changes of the same key are merged into at most one record.
65fn apply_change_to_slot(slot: &mut Option<Record<OwnedRow>>, is_insert: bool, row: OwnedRow) {
66    let prev = slot.take();
67    *slot = if is_insert {
68        match prev {
69            None => Some(Record::Insert { new_row: row }),
70            Some(Record::Delete { old_row }) => Some(Record::Update {
71                old_row,
72                new_row: row,
73            }),
74            Some(Record::Insert { .. }) => {
75                consistency_panic!("locality sort buffer: double-inserting the same key");
76                Some(Record::Insert { new_row: row })
77            }
78            Some(Record::Update { old_row, .. }) => {
79                consistency_panic!("locality sort buffer: double-inserting the same key");
80                Some(Record::Update {
81                    old_row,
82                    new_row: row,
83                })
84            }
85        }
86    } else {
87        match prev {
88            None => Some(Record::Delete { old_row: row }),
89            Some(Record::Insert { .. }) => None,
90            Some(Record::Update { old_row, .. }) => Some(Record::Delete { old_row }),
91            Some(Record::Delete { .. }) => {
92                consistency_panic!("locality sort buffer: double-deleting the same key");
93                Some(Record::Delete { old_row: row })
94            }
95        }
96    };
97}
98
99/// Take the compacted record of the current key, dropping no-op updates.
100fn take_compacted_record(slot: &mut Option<Record<OwnedRow>>) -> Option<Record<OwnedRow>> {
101    match slot.take() {
102        Some(Record::Update { old_row, new_row }) if old_row == new_row => None,
103        record => record,
104    }
105}
106
107/// Progress state for tracking backfill per vnode
108#[derive(Clone, Debug, PartialEq, Eq)]
109enum LocalityBackfillProgress {
110    /// Backfill not started for this vnode
111    NotStarted,
112    /// Backfill in progress, tracking current position
113    InProgress {
114        /// Current position in the locality-ordered scan
115        current_pos: OwnedRow,
116        /// Number of rows processed for this vnode
117        processed_rows: u64,
118    },
119    /// Backfill completed for this vnode
120    Completed {
121        /// Final position reached
122        final_pos: OwnedRow,
123        /// Total rows processed for this vnode
124        total_rows: u64,
125    },
126}
127
128/// State management for locality provider backfill process
129#[derive(Clone, Debug)]
130struct LocalityBackfillState {
131    /// Progress per vnode
132    per_vnode: HashMap<VirtualNode, LocalityBackfillProgress>,
133    /// Total snapshot rows read across all vnodes
134    total_snapshot_rows: u64,
135}
136
137impl LocalityBackfillState {
138    fn new(vnodes: impl Iterator<Item = VirtualNode>) -> Self {
139        let per_vnode = vnodes
140            .map(|vnode| (vnode, LocalityBackfillProgress::NotStarted))
141            .collect();
142        Self {
143            per_vnode,
144            total_snapshot_rows: 0,
145        }
146    }
147
148    fn is_completed(&self) -> bool {
149        self.per_vnode
150            .values()
151            .all(|progress| matches!(progress, LocalityBackfillProgress::Completed { .. }))
152    }
153
154    fn vnodes(&self) -> impl Iterator<Item = (VirtualNode, &LocalityBackfillProgress)> {
155        self.per_vnode
156            .iter()
157            .map(|(&vnode, progress)| (vnode, progress))
158    }
159
160    /// Like `BackfillState::has_progress` of arrangement backfill.
161    fn has_progress(&self) -> bool {
162        self.per_vnode
163            .values()
164            .any(|progress| !matches!(progress, LocalityBackfillProgress::NotStarted))
165    }
166
167    fn update_progress(&mut self, vnode: VirtualNode, new_pos: OwnedRow, row_count_delta: u64) {
168        let progress = self.per_vnode.get_mut(&vnode).unwrap();
169        match progress {
170            LocalityBackfillProgress::NotStarted => {
171                *progress = LocalityBackfillProgress::InProgress {
172                    current_pos: new_pos,
173                    processed_rows: row_count_delta,
174                };
175            }
176            LocalityBackfillProgress::InProgress { processed_rows, .. } => {
177                *progress = LocalityBackfillProgress::InProgress {
178                    current_pos: new_pos,
179                    processed_rows: *processed_rows + row_count_delta,
180                };
181            }
182            LocalityBackfillProgress::Completed { .. } => {
183                // Already completed, shouldn't update
184            }
185        }
186        self.total_snapshot_rows += row_count_delta;
187    }
188
189    fn finish_vnode(&mut self, vnode: VirtualNode, pk_len: usize) {
190        let progress = self.per_vnode.get_mut(&vnode).unwrap();
191        match progress {
192            LocalityBackfillProgress::NotStarted => {
193                // Create a final position with pk_len NULL values to indicate completion
194                let final_pos = OwnedRow::new(vec![None; pk_len]);
195                *progress = LocalityBackfillProgress::Completed {
196                    final_pos,
197                    total_rows: 0,
198                };
199            }
200            LocalityBackfillProgress::InProgress {
201                current_pos,
202                processed_rows,
203            } => {
204                *progress = LocalityBackfillProgress::Completed {
205                    final_pos: current_pos.clone(),
206                    total_rows: *processed_rows,
207                };
208            }
209            LocalityBackfillProgress::Completed { .. } => {
210                // Already completed
211            }
212        }
213    }
214
215    fn get_progress(&self, vnode: &VirtualNode) -> &LocalityBackfillProgress {
216        self.per_vnode.get(vnode).unwrap()
217    }
218}
219
220/// The `LocalityProviderExecutor` provides locality for operators during backfilling.
221/// It buffers input data into a state table using locality columns as primary key prefix.
222///
223/// The executor implements a proper backfill process similar to arrangement backfill:
224/// 1. Backfill phase: Buffer incoming data and provide locality-ordered snapshot reads
225/// 2. Completion transition: Drain buffered input and forward new input directly until the
226///    barrier that persists backfill completion
227/// 3. Forward phase: Optionally sort amplified epochs after that completion barrier
228///
229/// The sort buffer intentionally does not cover the completion transition. Input drained when the
230/// snapshot reaches EOF, or received while waiting for the completion barrier, is forwarded in
231/// arrival order. Only input after that barrier is eligible for sorting.
232///
233/// Key improvements over the original implementation:
234/// - Removes arbitrary barrier buffer limit
235/// - Implements proper upstream chunk tracking during backfill
236/// - Uses per-vnode progress tracking for better state management
237pub struct LocalityProviderExecutor<S: StateStore> {
238    /// Upstream input
239    upstream: Executor,
240
241    /// Locality columns (indices in input schema)
242    #[expect(dead_code)]
243    locality_columns: Vec<usize>,
244
245    /// State table for buffering input data
246    state_table: StateTable<S>,
247
248    /// Progress table for tracking backfill progress per vnode
249    progress_table: StateTable<S>,
250
251    /// Ephemeral sorted log table for buffering and re-ordering amplified input within an epoch
252    /// after backfill is finished. `None` if the feature was not enabled at plan time.
253    sort_buffer_table: Option<StateTable<S>>,
254
255    /// Runtime settings for the sort buffer.
256    sort_buffer_settings: SortBufferSettings,
257
258    input_schema: Schema,
259
260    /// Progress reporter for materialized view creation
261    progress: CreateMviewProgressReporter,
262
263    fragment_id: FragmentId,
264
265    actor_id: ActorId,
266
267    /// Metrics
268    metrics: Arc<StreamingMetrics>,
269
270    /// Chunk size for output
271    chunk_size: usize,
272
273    rate_limiter: MonitoredRateLimiter,
274}
275
276impl<S: StateStore> LocalityProviderExecutor<S> {
277    #[expect(clippy::too_many_arguments)]
278    pub fn new(
279        upstream: Executor,
280        locality_columns: Vec<usize>,
281        state_table: StateTable<S>,
282        progress_table: StateTable<S>,
283        sort_buffer_table: Option<StateTable<S>>,
284        sort_buffer_settings: SortBufferSettings,
285        input_schema: Schema,
286        progress: CreateMviewProgressReporter,
287        metrics: Arc<StreamingMetrics>,
288        chunk_size: usize,
289        fragment_id: FragmentId,
290        rate_limit: RateLimit,
291    ) -> Self {
292        let rate_limiter = RateLimiter::new(rate_limit).monitored(state_table.table_id());
293        Self {
294            upstream,
295            locality_columns,
296            state_table,
297            progress_table,
298            sort_buffer_table,
299            sort_buffer_settings,
300            input_schema,
301            actor_id: progress.actor_id(),
302            progress,
303            metrics,
304            chunk_size,
305            fragment_id,
306            rate_limiter,
307        }
308    }
309
310    /// Returns the new rate limit if it changed.
311    fn apply_throttle(
312        rate_limiter: &MonitoredRateLimiter,
313        fragment_id: FragmentId,
314        barrier: &Barrier,
315    ) -> Option<RateLimit> {
316        let Some(Mutation::Throttle(fragment_to_apply)) = barrier.mutation.as_deref() else {
317            return None;
318        };
319        let entry = fragment_to_apply.get(&fragment_id)?;
320        if entry.throttle_type() != ThrottleType::Backfill {
321            return None;
322        }
323        let new_rate_limit = entry.rate_limit.into();
324        let old_rate_limit = rate_limiter.update(new_rate_limit);
325        (old_rate_limit != new_rate_limit).then(|| {
326            tracing::info!(
327                ?old_rate_limit,
328                ?new_rate_limit,
329                %fragment_id,
330                "locality backfill rate limit changed"
331            );
332            new_rate_limit
333        })
334    }
335
336    /// Creates a snapshot stream that reads from state table in locality order
337    #[try_stream(ok = (VirtualNode, OwnedRow), error = StreamExecutorError)]
338    async fn make_snapshot_stream<'a>(
339        reader: FlushedStateTableReader<S>,
340        backfill_state: LocalityBackfillState,
341        rate_limiter: &'a MonitoredRateLimiter,
342    ) {
343        // Read from state table per vnode in locality order
344        for vnode in reader.vnodes().iter_vnodes() {
345            let progress = backfill_state.get_progress(&vnode);
346
347            let current_pos = match progress {
348                LocalityBackfillProgress::NotStarted => None,
349                LocalityBackfillProgress::Completed { .. } => {
350                    // Skip completed vnodes
351                    continue;
352                }
353                LocalityBackfillProgress::InProgress { current_pos, .. } => {
354                    Some(current_pos.clone())
355                }
356            };
357
358            // Compute range bounds for iteration based on current position
359            let range_bounds = if let Some(ref pos) = current_pos {
360                let start_bound = std::ops::Bound::Excluded(pos.as_inner());
361                (start_bound, std::ops::Bound::<&[Datum]>::Unbounded)
362            } else {
363                (
364                    std::ops::Bound::<&[Datum]>::Unbounded,
365                    std::ops::Bound::<&[Datum]>::Unbounded,
366                )
367            };
368
369            // Iterate over rows for this vnode
370            let iter = reader
371                .iter_with_vnode(
372                    vnode,
373                    &range_bounds,
374                    PrefetchOptions::prefetch_for_small_range_scan(),
375                )
376                .await?;
377            pin_mut!(iter);
378
379            while let Some(row) = iter.try_next().await? {
380                rate_limiter.wait(1).await;
381                yield (vnode, row);
382            }
383        }
384    }
385
386    /// Persist backfill state to progress table
387    async fn persist_backfill_state(
388        progress_table: &mut StateTable<S>,
389        backfill_state: &LocalityBackfillState,
390    ) -> StreamExecutorResult<()> {
391        for (vnode, progress) in &backfill_state.per_vnode {
392            let (is_finished, current_pos, row_count) = match progress {
393                LocalityBackfillProgress::NotStarted => continue, // Don't persist NotStarted
394                LocalityBackfillProgress::InProgress {
395                    current_pos,
396                    processed_rows,
397                } => (false, current_pos.clone(), *processed_rows),
398                LocalityBackfillProgress::Completed {
399                    final_pos,
400                    total_rows,
401                } => (true, final_pos.clone(), *total_rows),
402            };
403
404            // Build progress row: vnode + current_pos + is_finished + row_count
405            let mut row_data = vec![Some(vnode.to_scalar().into())];
406            row_data.extend(current_pos);
407            row_data.push(Some(risingwave_common::types::ScalarImpl::Bool(
408                is_finished,
409            )));
410            row_data.push(Some(risingwave_common::types::ScalarImpl::Int64(
411                row_count as i64,
412            )));
413
414            let new_row = OwnedRow::new(row_data);
415
416            // Check if there's an existing row for this vnode to determine insert vs update
417            // This ensures state operation consistency - update existing rows, insert new ones
418            let key_data = vec![Some(vnode.to_scalar().into())];
419            let key = OwnedRow::new(key_data);
420
421            if let Some(existing_row) = progress_table.get_row(&key).await? {
422                // Update existing state - ensures proper state transition for recovery
423                progress_table.update(existing_row, new_row);
424            } else {
425                // Insert new state - first time persisting for this vnode
426                progress_table.insert(new_row);
427            }
428        }
429        Ok(())
430    }
431
432    /// Load backfill state from progress table
433    async fn load_backfill_state(
434        progress_table: &StateTable<S>,
435    ) -> StreamExecutorResult<LocalityBackfillState> {
436        let mut backfill_state = LocalityBackfillState::new(progress_table.vnodes().iter_vnodes());
437        let mut total_snapshot_rows = 0;
438
439        // For each vnode, try to get its progress state
440        for vnode in progress_table.vnodes().iter_vnodes() {
441            // Build key: vnode + NULL values for locality columns (to match progress table schema)
442            let key_data = vec![Some(vnode.to_scalar().into())];
443
444            let key = OwnedRow::new(key_data);
445
446            if let Some(row) = progress_table.get_row(&key).await? {
447                // Parse is_finished flag (second to last column)
448                let finished_col_idx = row.len() - 2;
449                let is_finished = row
450                    .datum_at(finished_col_idx)
451                    .map(|d| d.into_bool())
452                    .unwrap_or(false);
453
454                // Parse row count (last column)
455                let row_count = row
456                    .datum_at(row.len() - 1)
457                    .map(|d| d.into_int64() as u64)
458                    .unwrap_or(0);
459
460                let current_pos_data: Vec<Datum> = (1..finished_col_idx)
461                    .map(|i| row.datum_at(i).to_owned_datum())
462                    .collect();
463                let current_pos = OwnedRow::new(current_pos_data);
464
465                // Set progress based on is_finished flag
466                let progress = if is_finished {
467                    LocalityBackfillProgress::Completed {
468                        final_pos: current_pos,
469                        total_rows: row_count,
470                    }
471                } else {
472                    LocalityBackfillProgress::InProgress {
473                        current_pos,
474                        processed_rows: row_count,
475                    }
476                };
477
478                backfill_state.per_vnode.insert(vnode, progress);
479                total_snapshot_rows += row_count;
480            }
481            // If no row found, keep the default NotStarted state
482        }
483
484        backfill_state.total_snapshot_rows = total_snapshot_rows;
485        Ok(backfill_state)
486    }
487
488    /// Mark chunk for forwarding based on backfill progress
489    fn mark_chunk(
490        chunk: StreamChunk,
491        backfill_state: &LocalityBackfillState,
492        state_table: &StateTable<S>,
493    ) -> StreamExecutorResult<StreamChunk> {
494        let chunk = chunk.compact_vis();
495        let (data, ops) = chunk.into_parts();
496        let mut new_visibility = risingwave_common::bitmap::BitmapBuilder::with_capacity(ops.len());
497
498        let pk_indices = state_table.pk_indices();
499        let pk_order = state_table.pk_serde().get_order_types();
500
501        for row in data.rows() {
502            // Project to primary key columns for comparison
503            let pk = row.project(pk_indices);
504            let vnode = state_table.compute_vnode_by_pk(pk);
505
506            let visible = match backfill_state.get_progress(&vnode) {
507                LocalityBackfillProgress::Completed { .. } => true,
508                LocalityBackfillProgress::NotStarted => false,
509                LocalityBackfillProgress::InProgress { current_pos, .. } => {
510                    // Compare primary key with current position
511                    cmp_datum_iter(pk.iter(), current_pos.iter(), pk_order.iter().copied()).is_le()
512                }
513            };
514
515            new_visibility.append(visible);
516        }
517
518        let (columns, _) = data.into_parts();
519        let chunk = StreamChunk::with_visibility(ops, columns, new_visibility.finish());
520        Ok(chunk)
521    }
522
523    fn handle_snapshot_chunk(
524        data_chunk: DataChunk,
525        vnode: VirtualNode,
526        pk_indices: &[usize],
527        backfill_state: &mut LocalityBackfillState,
528        cur_barrier_snapshot_processed_rows: &mut u64,
529    ) -> StreamExecutorResult<StreamChunk> {
530        let chunk = StreamChunk::from_parts(vec![Op::Insert; data_chunk.cardinality()], data_chunk);
531        let chunk_cardinality = chunk.cardinality() as u64;
532
533        // Extract primary key from the last row to update progress
534        // As snapshot read streams are ordered by pk, we can use the last row to update current_pos
535        if let Some(last_row) = chunk.rows().last() {
536            let pk = last_row.1.project(pk_indices);
537            let pk_owned = pk.into_owned_row();
538            backfill_state.update_progress(vnode, pk_owned, chunk_cardinality);
539        }
540
541        *cur_barrier_snapshot_processed_rows += chunk_cardinality;
542        Ok(chunk)
543    }
544
545    /// Write a compacted input chunk into the sort buffer table as an append-only log.
546    /// Each row is extended with `gen` (the current epoch generation), `op` (normalized op code)
547    /// and `seq` (monotonic sequence number within the epoch), all writes using `Op::Insert` with
548    /// unique keys.
549    fn write_chunk_to_sort_buffer(
550        sort_buffer_table: &mut StateTable<S>,
551        chunk: &StreamChunk,
552        generation: i64,
553        seq: &mut i64,
554    ) {
555        let cardinality = chunk.cardinality();
556        debug_assert_eq!(
557            cardinality,
558            chunk.capacity(),
559            "chunk must be compacted before buffering"
560        );
561
562        let mut columns = chunk.data_chunk().columns().to_vec();
563        columns.push(Arc::new(ArrayImpl::Int64(I64Array::from_iter(
564            std::iter::repeat_n(Some(generation), cardinality),
565        ))));
566        columns.push(Arc::new(ArrayImpl::Int16(I16Array::from_iter(
567            chunk.ops().iter().map(|op| Some(op_to_code(*op))),
568        ))));
569        columns.push(Arc::new(ArrayImpl::Int64(I64Array::from_iter(
570            (*seq..*seq + cardinality as i64).map(Some),
571        ))));
572        *seq += cardinality as i64;
573
574        let log_chunk = StreamChunk::new(vec![Op::Insert; cardinality], columns);
575        sort_buffer_table.write_chunk(log_chunk);
576    }
577
578    /// Replay this epoch's buffered changes from the sort buffer table in locality order,
579    /// compacting consecutive changes of the same key (locality columns + stream key) into at
580    /// most one record.
581    ///
582    /// Must be called *after* the sort buffer table has been committed for the epoch, so that all
583    /// buffered rows are visible to the flushed snapshot reader (staging imms + spilled SSTs).
584    #[try_stream(ok = StreamChunk, error = StreamExecutorError)]
585    async fn make_sort_buffer_replay_stream(
586        reader: FlushedStateTableReader<S>,
587        generation: i64,
588        group_key_indices: Vec<usize>,
589        input_len: usize,
590        data_types: Vec<DataType>,
591        chunk_size: usize,
592    ) {
593        // Scan exactly this epoch's generation via a prefix range on the first pk column.
594        let range = (
595            Bound::Included(OwnedRow::new(vec![Some(ScalarImpl::Int64(generation))])),
596            Bound::Excluded(OwnedRow::new(vec![Some(ScalarImpl::Int64(generation + 1))])),
597        );
598
599        let mut chunk_builder = StreamChunkBuilder::new(chunk_size, data_types);
600        let mut cur_key: Option<OwnedRow> = None;
601        let mut slot: Option<Record<OwnedRow>> = None;
602
603        // Each key belongs to exactly one vnode, so a per-vnode sequential scan already yields
604        // all changes of one key contiguously, which is what downstream locality needs.
605        for vnode in reader.vnodes().iter_vnodes() {
606            let iter = reader
607                .iter_with_vnode(
608                    vnode,
609                    &range,
610                    PrefetchOptions::prefetch_for_large_range_scan(),
611                )
612                .await?;
613            pin_mut!(iter);
614
615            while let Some(row) = iter.try_next().await? {
616                // Row layout: input columns ++ [gen, op, seq]
617                let is_insert = row
618                    .datum_at(input_len + 1)
619                    .expect("op column must not be null")
620                    .into_int16()
621                    == Op::Insert.to_i16();
622
623                let same_key = match &cur_key {
624                    Some(key) => (&row).project(&group_key_indices).iter().eq(key.iter()),
625                    None => false,
626                };
627                if !same_key {
628                    if let Some(record) = take_compacted_record(&mut slot)
629                        && let Some(chunk) = chunk_builder.append_record(record)
630                    {
631                        yield chunk;
632                    }
633                    cur_key = Some((&row).project(&group_key_indices).into_owned_row());
634                }
635
636                let input_row = OwnedRow::new(row.as_inner()[..input_len].to_vec());
637                apply_change_to_slot(&mut slot, is_insert, input_row);
638            }
639        }
640
641        if let Some(record) = take_compacted_record(&mut slot)
642            && let Some(chunk) = chunk_builder.append_record(record)
643        {
644            yield chunk;
645        }
646        if let Some(chunk) = chunk_builder.take() {
647            yield chunk;
648        }
649    }
650}
651
652impl<S: StateStore> Execute for LocalityProviderExecutor<S> {
653    fn execute(self: Box<Self>) -> BoxedMessageStream {
654        self.execute_inner().boxed()
655    }
656}
657
658impl<S: StateStore> LocalityProviderExecutor<S> {
659    #[try_stream(ok = Message, error = StreamExecutorError)]
660    async fn execute_inner(mut self) {
661        let mut upstream = self.upstream.execute();
662
663        // Wait for first barrier to initialize
664        let first_barrier = expect_first_barrier(&mut upstream).await?;
665        let first_epoch = first_barrier.epoch;
666
667        // Propagate the first barrier
668        yield Message::Barrier(first_barrier);
669
670        let mut state_table = self.state_table;
671        let mut progress_table = self.progress_table;
672        let mut sort_buffer_table = self.sort_buffer_table;
673        let rate_limiter = self.rate_limiter;
674
675        // Initialize state tables
676        state_table.init_epoch(first_epoch).await?;
677        progress_table.init_epoch(first_epoch).await?;
678        if let Some(sort_table) = &mut sort_buffer_table {
679            sort_table.init_epoch(first_epoch).await?;
680        }
681
682        // The current write epoch, used as the generation (`gen`) of buffered changes in the
683        // sort buffer table. Updated at every barrier.
684        let mut cur_gen: u64 = first_epoch.curr;
685
686        // Load backfill state from progress table
687        let mut backfill_state = Self::load_backfill_state(&progress_table).await?;
688
689        // Get pk info from state table
690        let pk_indices = state_table.pk_indices().iter().cloned().collect_vec();
691
692        let need_backfill = !backfill_state.is_completed();
693        let mut report_finished_on_first_barrier = !need_backfill;
694
695        let need_buffering = backfill_state
696            .per_vnode
697            .values()
698            .all(|progress| matches!(progress, LocalityBackfillProgress::NotStarted));
699        // Initial buffering phase before backfill - wait for StartFragmentBackfill mutation (if needed)
700        if need_buffering {
701            // Enter buffering phase - buffer data until StartFragmentBackfill is received
702            let mut start_backfill = false;
703
704            #[for_await]
705            for msg in upstream.by_ref() {
706                let msg = msg?;
707
708                match msg {
709                    Message::Watermark(_) => {
710                        // Ignore watermarks during initial buffering
711                    }
712                    Message::Chunk(chunk) => {
713                        state_table.write_chunk(chunk);
714                        state_table.try_flush().await?;
715                    }
716                    Message::Barrier(barrier) => {
717                        let epoch = barrier.epoch;
718                        Self::apply_throttle(&rate_limiter, self.fragment_id, &barrier);
719
720                        // Check for StartFragmentBackfill mutation
721                        if let Some(mutation) = barrier.mutation.as_deref() {
722                            use crate::executor::Mutation;
723                            if let Mutation::StartFragmentBackfill { fragment_ids } = mutation
724                                && fragment_ids.contains(&self.fragment_id)
725                            {
726                                tracing::info!(
727                                    "Start backfill of locality provider with fragment id: {:?}",
728                                    &self.fragment_id
729                                );
730                                start_backfill = true;
731                            }
732                        }
733
734                        // Commit state tables
735                        barrier.assume_no_update_vnode_bitmap(self.actor_id)?;
736                        state_table
737                            .commit_assert_no_update_vnode_bitmap(epoch)
738                            .await?;
739                        progress_table
740                            .commit_assert_no_update_vnode_bitmap(epoch)
741                            .await?;
742                        if let Some(sort_table) = &mut sort_buffer_table {
743                            sort_table
744                                .commit_assert_no_update_vnode_bitmap(epoch)
745                                .await?;
746                        }
747                        cur_gen = epoch.curr;
748
749                        yield Message::Barrier(barrier);
750
751                        // Start backfill when StartFragmentBackfill mutation is received
752                        if start_backfill {
753                            break;
754                        }
755                    }
756                }
757            }
758        }
759
760        // Locality Provider Backfill Algorithm (adapted from Arrangement Backfill):
761        //
762        //   backfill_stream
763        //  /               \
764        // upstream       snapshot (from state_table)
765        //
766        // We construct a backfill stream with upstream as its left input and locality-ordered
767        // snapshot read stream as its right input. When a chunk comes from upstream, we buffer it.
768        //
769        // When a barrier comes from upstream:
770        //  - For each row of the upstream chunk buffer, compute vnode.
771        //  - Get the `current_pos` corresponding to the vnode. Forward it to downstream if its
772        //    locality key <= `current_pos`, otherwise ignore it.
773        //  - Flush all buffered upstream_chunks to state table.
774        //  - Persist backfill progress to progress table.
775        //  - Reconstruct the snapshot read stream only if buffered upstream chunks changed the
776        //    state table. Otherwise, continue the same snapshot read stream across the barrier.
777        //
778        // When a chunk comes from snapshot, we forward it to the downstream and raise
779        // `current_pos`.
780        //
781        // When we reach the end of the snapshot read stream, it means backfill has been
782        // finished.
783        //
784        // Once the backfill loop ends, we forward the upstream directly to the downstream.
785
786        if need_backfill {
787            let mut upstream_chunk_buffer: Vec<StreamChunk> = vec![];
788
789            let metrics = self
790                .metrics
791                .new_backfill_metrics(state_table.table_id(), self.actor_id);
792
793            // Create builders for snapshot data chunks
794            let snapshot_data_types = self.input_schema.data_types();
795            let vnodes = state_table.vnodes().clone();
796            let new_builders = |rate_limit| -> Builders {
797                vnodes
798                    .iter_vnodes()
799                    .map(|vnode| {
800                        let builder = create_builder(
801                            rate_limit,
802                            self.chunk_size,
803                            snapshot_data_types.clone(),
804                        );
805                        (vnode, builder)
806                    })
807                    .collect()
808            };
809            let mut builders = new_builders(rate_limiter.rate_limit());
810
811            let snapshot_reader = state_table.flushed_snapshot_reader();
812            let snapshot_stream = Self::make_snapshot_stream(
813                snapshot_reader.clone(),
814                backfill_state.clone(),
815                &rate_limiter,
816            );
817            pin_mut!(snapshot_stream);
818
819            'backfill_loop: loop {
820                let mut cur_barrier_snapshot_processed_rows: u64 = 0;
821                let mut cur_barrier_upstream_processed_rows: u64 = 0;
822
823                // Prefer upstream so a ready barrier can pause snapshot output promptly, while
824                // keeping the snapshot stream itself alive across barriers with no upstream data.
825                let barrier = loop {
826                    let upstream_next = upstream.next();
827                    let mut snapshot_stream_ref = snapshot_stream.as_mut();
828                    let snapshot_paused = rate_limiter.rate_limit().is_paused();
829                    let snapshot_next = async move {
830                        if snapshot_paused {
831                            pending().await
832                        } else {
833                            snapshot_stream_ref.next().await
834                        }
835                    };
836                    pin_mut!(upstream_next);
837                    pin_mut!(snapshot_next);
838
839                    match select(upstream_next, snapshot_next).await {
840                        FutureEither::Left((msg, _)) => match msg.transpose()? {
841                            Some(Message::Barrier(barrier)) => {
842                                // Process the barrier after draining the snapshot builders.
843                                break barrier;
844                            }
845                            Some(Message::Chunk(chunk)) => {
846                                // Buffer the upstream chunk.
847                                upstream_chunk_buffer.push(chunk.compact_vis());
848                            }
849                            Some(Message::Watermark(_)) => {
850                                // Ignore watermark during backfill.
851                            }
852                            None => {
853                                return Err(anyhow::anyhow!(
854                                    "locality provider upstream ended unexpectedly during backfill"
855                                )
856                                .into());
857                            }
858                        },
859                        FutureEither::Right((msg, _)) => match msg.transpose()? {
860                            Some((vnode, row)) => {
861                                // Use builder to batch rows efficiently
862                                let builder = builders.get_mut(&vnode).unwrap();
863                                if let Some(data_chunk) = builder.append_one_row(row) {
864                                    // Builder is full, handle the chunk
865                                    let chunk = Self::handle_snapshot_chunk(
866                                        data_chunk,
867                                        vnode,
868                                        &pk_indices,
869                                        &mut backfill_state,
870                                        &mut cur_barrier_snapshot_processed_rows,
871                                    )?;
872                                    yield Message::Chunk(chunk);
873                                }
874                                // If append_one_row returns None, row is buffered but no chunk is produced yet
875                                // Progress will be updated when the builder is consumed later
876                            }
877                            None => {
878                                // End of the snapshot read stream.
879                                // Consume remaining rows in the builders.
880                                for (vnode, builder) in &mut builders {
881                                    if let Some(data_chunk) = builder.consume_all() {
882                                        let chunk = Self::handle_snapshot_chunk(
883                                            data_chunk,
884                                            *vnode,
885                                            &pk_indices,
886                                            &mut backfill_state,
887                                            &mut cur_barrier_snapshot_processed_rows,
888                                        )?;
889                                        yield Message::Chunk(chunk);
890                                    }
891                                }
892
893                                // Consume remaining rows in the upstream buffer.
894                                for chunk in upstream_chunk_buffer.drain(..) {
895                                    let chunk_cardinality = chunk.cardinality() as u64;
896                                    cur_barrier_upstream_processed_rows += chunk_cardinality;
897                                    yield Message::Chunk(chunk);
898                                }
899                                metrics
900                                    .backfill_snapshot_read_row_count
901                                    .inc_by(cur_barrier_snapshot_processed_rows);
902                                metrics
903                                    .backfill_upstream_output_row_count
904                                    .inc_by(cur_barrier_upstream_processed_rows);
905                                break 'backfill_loop;
906                            }
907                        },
908                    }
909                };
910
911                // Consume remaining rows from builders at barrier
912                for (vnode, builder) in &mut builders {
913                    if let Some(data_chunk) = builder.consume_all() {
914                        let chunk = Self::handle_snapshot_chunk(
915                            data_chunk,
916                            *vnode,
917                            &pk_indices,
918                            &mut backfill_state,
919                            &mut cur_barrier_snapshot_processed_rows,
920                        )?;
921                        yield Message::Chunk(chunk);
922                    }
923                }
924
925                if let Some(new_rate_limit) =
926                    Self::apply_throttle(&rate_limiter, self.fragment_id, &barrier)
927                {
928                    builders = new_builders(new_rate_limit);
929                }
930
931                // Process upstream buffer chunks with marking
932                let should_refresh_snapshot = !upstream_chunk_buffer.is_empty();
933                for chunk in upstream_chunk_buffer.drain(..) {
934                    cur_barrier_upstream_processed_rows += chunk.cardinality() as u64;
935
936                    // Mark chunk based on backfill progress
937                    if backfill_state.has_progress() {
938                        let marked_chunk =
939                            Self::mark_chunk(chunk.clone(), &backfill_state, &state_table)?;
940                        yield Message::Chunk(marked_chunk);
941                    }
942
943                    // Persist buffered upstream chunk into state table so subsequent snapshot
944                    // iterations see the latest writes.
945                    state_table.write_chunk(chunk);
946                }
947
948                let barrier_epoch = barrier.epoch;
949                barrier.assume_no_update_vnode_bitmap(self.actor_id)?;
950                state_table
951                    .commit_assert_no_update_vnode_bitmap(barrier_epoch)
952                    .await?;
953                if let Some(sort_table) = &mut sort_buffer_table {
954                    sort_table
955                        .commit_assert_no_update_vnode_bitmap(barrier_epoch)
956                        .await?;
957                }
958                cur_gen = barrier_epoch.curr;
959
960                // Update progress with current epoch and snapshot read count
961                // Report both consumed rows and buffered rows separately for precise progress
962                let total_snapshot_processed_rows: u64 = backfill_state
963                    .vnodes()
964                    .map(|(_, progress)| match *progress {
965                        LocalityBackfillProgress::InProgress { processed_rows, .. } => {
966                            processed_rows
967                        }
968                        LocalityBackfillProgress::Completed { total_rows, .. } => total_rows,
969                        LocalityBackfillProgress::NotStarted => 0,
970                    })
971                    .sum();
972
973                self.progress.update_with_buffered_rows(
974                    barrier.epoch,
975                    barrier.epoch.curr, // Use barrier epoch as snapshot read epoch
976                    total_snapshot_processed_rows,
977                    0,
978                );
979
980                // Persist backfill progress
981                Self::persist_backfill_state(&mut progress_table, &backfill_state).await?;
982                progress_table
983                    .commit_assert_no_update_vnode_bitmap(barrier_epoch)
984                    .await?;
985
986                metrics
987                    .backfill_snapshot_read_row_count
988                    .inc_by(cur_barrier_snapshot_processed_rows);
989                metrics
990                    .backfill_upstream_output_row_count
991                    .inc_by(cur_barrier_upstream_processed_rows);
992
993                yield Message::Barrier(barrier);
994
995                if should_refresh_snapshot {
996                    snapshot_stream.set(Self::make_snapshot_stream(
997                        snapshot_reader.clone(),
998                        backfill_state.clone(),
999                        &rate_limiter,
1000                    ));
1001                }
1002            }
1003        }
1004
1005        tracing::debug!("Locality provider backfill finished, forwarding upstream directly");
1006
1007        // Wait for the first barrier after snapshot EOF to mark progress as finished. Chunks in
1008        // this transition are forwarded directly; the sort buffer starts only after this barrier.
1009        if need_backfill && !backfill_state.is_completed() {
1010            while let Some(Ok(msg)) = upstream.next().await {
1011                match msg {
1012                    Message::Barrier(barrier) => {
1013                        barrier.assume_no_update_vnode_bitmap(self.actor_id)?;
1014
1015                        // no-op commit state table
1016                        state_table
1017                            .commit_assert_no_update_vnode_bitmap(barrier.epoch)
1018                            .await?;
1019                        if let Some(sort_table) = &mut sort_buffer_table {
1020                            sort_table
1021                                .commit_assert_no_update_vnode_bitmap(barrier.epoch)
1022                                .await?;
1023                        }
1024                        cur_gen = barrier.epoch.curr;
1025
1026                        // Mark all vnodes as completed
1027                        for vnode in state_table.vnodes().iter_vnodes() {
1028                            backfill_state.finish_vnode(vnode, pk_indices.len());
1029                        }
1030
1031                        // Calculate final total processed rows
1032                        let total_snapshot_processed_rows: u64 = backfill_state
1033                            .vnodes()
1034                            .map(|(_, progress)| match *progress {
1035                                LocalityBackfillProgress::Completed { total_rows, .. } => {
1036                                    total_rows
1037                                }
1038                                LocalityBackfillProgress::InProgress { processed_rows, .. } => {
1039                                    processed_rows
1040                                }
1041                                LocalityBackfillProgress::NotStarted => 0,
1042                            })
1043                            .sum();
1044
1045                        // Finish progress reporting with any remaining buffered rows
1046                        // At completion, we report `total_snapshot_processed_rows` as buffered rows to make progress accurate.
1047                        self.progress.finish_with_buffered_rows(
1048                            barrier.epoch,
1049                            total_snapshot_processed_rows,
1050                            total_snapshot_processed_rows,
1051                        );
1052
1053                        // Persist final state
1054                        Self::persist_backfill_state(&mut progress_table, &backfill_state).await?;
1055                        progress_table
1056                            .commit_assert_no_update_vnode_bitmap(barrier.epoch)
1057                            .await?;
1058
1059                        yield Message::Barrier(barrier);
1060                        break; // Exit the loop after processing the barrier
1061                    }
1062                    Message::Chunk(chunk) => {
1063                        // Forward chunks directly during completion phase
1064                        yield Message::Chunk(chunk);
1065                    }
1066                    Message::Watermark(watermark) => {
1067                        // Forward watermarks directly during completion phase
1068                        yield Message::Watermark(watermark);
1069                    }
1070                }
1071            }
1072        }
1073
1074        // After the completion barrier, forward messages directly. When the sort buffer is enabled
1075        // and an epoch turns out to be amplified (row count exceeds the activation threshold),
1076        // switch to buffering the rest of the epoch into the sort buffer table and replay it in
1077        // locality order at the barrier, so that downstream operators process the amplified
1078        // changes with much better cache locality, while the buffer memory stays bounded by the
1079        // shared buffer (spillable to L0).
1080        let sort_buffer_active = sort_buffer_table.is_some() && self.sort_buffer_settings.enabled;
1081        let activate_threshold = self.sort_buffer_settings.activate_threshold;
1082
1083        // The group key (locality columns + stream key) for per-key compaction during replay.
1084        // It equals the pk of the buffer state table (which is `gen` + group key + `seq`) without
1085        // the `gen`/`seq` columns, and also equals the pk of the backfill state table.
1086        let group_key_indices = state_table.pk_indices().to_vec();
1087        let input_data_types = self.input_schema.data_types();
1088        let input_len = self.input_schema.len();
1089
1090        // Per-epoch states of the sort buffer. `buffering` switches one way (pass-through ->
1091        // buffering) within an epoch and resets at barriers, so that per-key ordering is
1092        // preserved: the passed-through prefix is emitted before any buffered change.
1093        let mut buffering = false;
1094        let mut rows_in_epoch: u64 = 0;
1095        let mut seq: i64 = 0;
1096        // Advance the state-clean watermark at the next barrier: initially true to clean
1097        // possible leftovers of previous incarnations after recovery, and set again after each
1098        // buffered epoch to clean its generation.
1099        let mut needs_cleanup = true;
1100        // Watermark messages held back while buffering, keyed by column index. They are emitted
1101        // after the replayed chunks so that they never precede the buffered rows they constrain.
1102        let mut pending_watermarks: BTreeMap<usize, Watermark> = BTreeMap::new();
1103
1104        #[for_await]
1105        for msg in upstream {
1106            let msg = msg?;
1107
1108            match msg {
1109                Message::Chunk(chunk) => {
1110                    if !buffering {
1111                        rows_in_epoch += chunk.cardinality() as u64;
1112                        if sort_buffer_active && rows_in_epoch > activate_threshold {
1113                            tracing::info!(
1114                                actor_id = %self.actor_id,
1115                                fragment_id = %self.fragment_id,
1116                                rows_in_epoch,
1117                                activate_threshold,
1118                                "locality provider sort buffer activated for this epoch"
1119                            );
1120                            buffering = true;
1121                        }
1122                    }
1123                    if buffering {
1124                        let chunk = chunk.compact_vis();
1125                        let sort_table = sort_buffer_table.as_mut().unwrap();
1126                        Self::write_chunk_to_sort_buffer(
1127                            sort_table,
1128                            &chunk,
1129                            cur_gen as i64,
1130                            &mut seq,
1131                        );
1132                        sort_table.try_flush().await?;
1133                    } else {
1134                        yield Message::Chunk(chunk);
1135                    }
1136                }
1137                Message::Watermark(watermark) => {
1138                    if buffering {
1139                        pending_watermarks.insert(watermark.col_idx, watermark);
1140                    } else {
1141                        yield Message::Watermark(watermark);
1142                    }
1143                }
1144                Message::Barrier(barrier) => {
1145                    barrier.assume_no_update_vnode_bitmap(self.actor_id)?;
1146
1147                    // Commit state tables but don't modify them
1148                    state_table
1149                        .commit_assert_no_update_vnode_bitmap(barrier.epoch)
1150                        .await?;
1151                    progress_table
1152                        .commit_assert_no_update_vnode_bitmap(barrier.epoch)
1153                        .await?;
1154
1155                    if let Some(sort_table) = &mut sort_buffer_table {
1156                        debug_assert_eq!(cur_gen, barrier.epoch.prev);
1157
1158                        if buffering || needs_cleanup {
1159                            // Advancing the watermark to the current generation invalidates all
1160                            // older generations, which have been fully replayed before their
1161                            // barriers (or discarded by recovery). The current generation
1162                            // survives (cleaning is exclusive) and gets cleaned at the next
1163                            // such barrier.
1164                            sort_table.update_watermark(ScalarImpl::Int64(cur_gen as i64));
1165                        }
1166                        // Commit *before* replay: sealing the epoch makes all buffered rows of
1167                        // this epoch stably visible to the flushed snapshot reader.
1168                        sort_table
1169                            .commit_assert_no_update_vnode_bitmap(barrier.epoch)
1170                            .await?;
1171
1172                        if buffering {
1173                            let replay_stream = Self::make_sort_buffer_replay_stream(
1174                                sort_table.flushed_snapshot_reader(),
1175                                cur_gen as i64,
1176                                group_key_indices.clone(),
1177                                input_len,
1178                                input_data_types.clone(),
1179                                self.chunk_size,
1180                            );
1181                            pin_mut!(replay_stream);
1182                            while let Some(chunk) = replay_stream.try_next().await? {
1183                                yield Message::Chunk(chunk);
1184                            }
1185
1186                            for (_, watermark) in std::mem::take(&mut pending_watermarks) {
1187                                yield Message::Watermark(watermark);
1188                            }
1189                        }
1190                        needs_cleanup = buffering;
1191                    }
1192
1193                    if report_finished_on_first_barrier {
1194                        // At completion, we report `total_snapshot_rows` as buffered rows to make progress accurate.
1195                        self.progress.finish_with_buffered_rows(
1196                            barrier.epoch,
1197                            backfill_state.total_snapshot_rows,
1198                            backfill_state.total_snapshot_rows,
1199                        );
1200                        report_finished_on_first_barrier = false;
1201                    }
1202
1203                    cur_gen = barrier.epoch.curr;
1204                    buffering = false;
1205                    rows_in_epoch = 0;
1206                    seq = 0;
1207
1208                    yield Message::Barrier(barrier);
1209                }
1210            }
1211        }
1212    }
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217    use std::time::Duration;
1218
1219    use risingwave_common::array::StreamChunkTestExt;
1220    use risingwave_common::catalog::{ColumnDesc, ColumnId, Field, Schema, TableId};
1221    use risingwave_common::hash::VirtualNode;
1222    use risingwave_common::types::DataType;
1223    use risingwave_common::util::epoch::{EpochPair, test_epoch};
1224    use risingwave_common::util::sort_util::OrderType;
1225    use risingwave_storage::memory::MemoryStateStore;
1226    use tokio::time::timeout;
1227
1228    use super::*;
1229    use crate::common::table::test_utils::gen_pbtable;
1230    use crate::executor::test_utils::{MockSource, StreamExecutorTestExt};
1231    use crate::task::LocalBarrierManager;
1232
1233    fn row(values: impl IntoIterator<Item = i32>) -> OwnedRow {
1234        OwnedRow::new(
1235            values
1236                .into_iter()
1237                .map(|v| Some(ScalarImpl::Int32(v)))
1238                .collect(),
1239        )
1240    }
1241
1242    /// Destructure a record into comparable parts, as [`Record`] does not implement `PartialEq`.
1243    fn parts(record: Option<Record<OwnedRow>>) -> Option<(&'static str, Vec<OwnedRow>)> {
1244        record.map(|record| match record {
1245            Record::Insert { new_row } => ("insert", vec![new_row]),
1246            Record::Delete { old_row } => ("delete", vec![old_row]),
1247            Record::Update { old_row, new_row } => ("update", vec![old_row, new_row]),
1248        })
1249    }
1250
1251    #[test]
1252    fn test_per_key_compaction_slot() {
1253        // Insert then delete cancels out.
1254        let mut slot = None;
1255        apply_change_to_slot(&mut slot, true, row([1]));
1256        apply_change_to_slot(&mut slot, false, row([1]));
1257        assert_eq!(parts(take_compacted_record(&mut slot)), None);
1258
1259        // Delete then insert becomes an update.
1260        let mut slot = None;
1261        apply_change_to_slot(&mut slot, false, row([1]));
1262        apply_change_to_slot(&mut slot, true, row([2]));
1263        assert_eq!(
1264            parts(take_compacted_record(&mut slot)),
1265            Some(("update", vec![row([1]), row([2])]))
1266        );
1267
1268        // Delete, insert, delete collapses to a single delete of the original row.
1269        let mut slot = None;
1270        apply_change_to_slot(&mut slot, false, row([1]));
1271        apply_change_to_slot(&mut slot, true, row([2]));
1272        apply_change_to_slot(&mut slot, false, row([2]));
1273        assert_eq!(
1274            parts(take_compacted_record(&mut slot)),
1275            Some(("delete", vec![row([1])]))
1276        );
1277
1278        // No-op update (same old and new row) is dropped.
1279        let mut slot = None;
1280        apply_change_to_slot(&mut slot, false, row([1]));
1281        apply_change_to_slot(&mut slot, true, row([1]));
1282        assert_eq!(parts(take_compacted_record(&mut slot)), None);
1283
1284        // Plain insert is kept.
1285        let mut slot = None;
1286        apply_change_to_slot(&mut slot, true, row([1]));
1287        assert_eq!(
1288            parts(take_compacted_record(&mut slot)),
1289            Some(("insert", vec![row([1])]))
1290        );
1291    }
1292
1293    /// Write interleaved changes of one epoch into the sort buffer table, commit, and replay.
1294    /// Verifies that the replay is grouped by key (locality + stream key), per-key compacted,
1295    /// and only covers the requested generation.
1296    #[tokio::test]
1297    async fn test_sort_buffer_write_and_replay() {
1298        // Input schema: a (locality column), b (stream key), c (payload)
1299        let input_data_types = vec![DataType::Int32, DataType::Int32, DataType::Int32];
1300        let input_len = input_data_types.len();
1301        let group_key_indices = vec![0, 1];
1302
1303        // Buffer table: input columns ++ [_rw_gen, _rw_op, _rw_seq],
1304        // pk = gen + locality (a) + stream key (b) + seq
1305        let column_descs = vec![
1306            ColumnDesc::unnamed(ColumnId::new(0), DataType::Int32),
1307            ColumnDesc::unnamed(ColumnId::new(1), DataType::Int32),
1308            ColumnDesc::unnamed(ColumnId::new(2), DataType::Int32),
1309            ColumnDesc::unnamed(ColumnId::new(3), DataType::Int64),
1310            ColumnDesc::unnamed(ColumnId::new(4), DataType::Int16),
1311            ColumnDesc::unnamed(ColumnId::new(5), DataType::Int64),
1312        ];
1313        let pk_indices = vec![3, 0, 1, 5];
1314        let order_types = vec![OrderType::ascending(); 4];
1315
1316        let mut table = StateTable::from_table_catalog(
1317            &gen_pbtable(TableId::new(1), column_descs, order_types, pk_indices, 0),
1318            MemoryStateStore::new(),
1319            None,
1320        )
1321        .await;
1322
1323        let mut epoch = EpochPair::new_test_epoch(test_epoch(1));
1324        table.init_epoch(epoch).await.unwrap();
1325
1326        let generation = test_epoch(1) as i64;
1327        let mut seq = 0;
1328
1329        // Changes of the current generation, arriving in arbitrary key order:
1330        // - key (3, 1): insert then delete -> cancels out
1331        // - key (1, 1): plain insert
1332        // - key (2, 1): update pair
1333        // - key (1, 2): insert
1334        let chunk = StreamChunk::from_pretty(
1335            "  i i i
1336             + 3 1 100
1337             + 1 1 10
1338            U- 2 1 20
1339            U+ 2 1 21
1340             + 1 2 11
1341             - 3 1 100",
1342        );
1343        LocalityProviderExecutor::<MemoryStateStore>::write_chunk_to_sort_buffer(
1344            &mut table, &chunk, generation, &mut seq,
1345        );
1346        assert_eq!(seq, 6);
1347
1348        // Changes of a different (newer) generation must not appear in the replay.
1349        let other_chunk = StreamChunk::from_pretty(
1350            " i i i
1351            + 9 9 99",
1352        );
1353        let mut other_seq = 0;
1354        LocalityProviderExecutor::<MemoryStateStore>::write_chunk_to_sort_buffer(
1355            &mut table,
1356            &other_chunk,
1357            test_epoch(2) as i64,
1358            &mut other_seq,
1359        );
1360
1361        // Commit before replay, mirroring the executor's barrier handling.
1362        epoch.inc_for_test();
1363        table.commit_for_test(epoch).await.unwrap();
1364
1365        let replay_stream =
1366            LocalityProviderExecutor::<MemoryStateStore>::make_sort_buffer_replay_stream(
1367                table.flushed_snapshot_reader(),
1368                generation,
1369                group_key_indices,
1370                input_len,
1371                input_data_types,
1372                2,
1373            );
1374        pin_mut!(replay_stream);
1375        let mut chunks = vec![];
1376        while let Some(chunk) = replay_stream.try_next().await.unwrap() {
1377            chunks.push(chunk);
1378        }
1379
1380        // Replay is ordered by (a, b), per-key compacted, with the cancelled key dropped.
1381        assert_eq!(chunks.len(), 2);
1382        assert_eq!(
1383            chunks[0],
1384            StreamChunk::from_pretty(
1385                " i i i
1386                + 1 1 10
1387                + 1 2 11",
1388            )
1389        );
1390        assert_eq!(
1391            chunks[1],
1392            StreamChunk::from_pretty(
1393                "  i i i
1394                U- 2 1 20
1395                U+ 2 1 21",
1396            )
1397        );
1398    }
1399
1400    #[tokio::test]
1401    async fn test_sort_buffer_starts_after_backfill_completion_barrier() {
1402        let store = MemoryStateStore::new();
1403        let input_schema = Schema::new(vec![
1404            Field::unnamed(DataType::Int32),
1405            Field::unnamed(DataType::Int32),
1406            Field::unnamed(DataType::Int32),
1407        ]);
1408        let input_columns = vec![
1409            ColumnDesc::unnamed(ColumnId::new(0), DataType::Int32),
1410            ColumnDesc::unnamed(ColumnId::new(1), DataType::Int32),
1411            ColumnDesc::unnamed(ColumnId::new(2), DataType::Int32),
1412        ];
1413
1414        // State table: input columns, pk = locality column + stream key.
1415        let state_table = StateTable::from_table_catalog(
1416            &gen_pbtable(
1417                TableId::new(1),
1418                input_columns.clone(),
1419                vec![OrderType::ascending(); 2],
1420                vec![0, 1],
1421                0,
1422            ),
1423            store.clone(),
1424            None,
1425        )
1426        .await;
1427
1428        // Progress table: vnode + state-table pk + completion flag + processed rows.
1429        let progress_catalog = gen_pbtable(
1430            TableId::new(2),
1431            vec![
1432                ColumnDesc::unnamed(ColumnId::new(0), VirtualNode::RW_TYPE),
1433                ColumnDesc::unnamed(ColumnId::new(1), DataType::Int32),
1434                ColumnDesc::unnamed(ColumnId::new(2), DataType::Int32),
1435                ColumnDesc::unnamed(ColumnId::new(3), DataType::Boolean),
1436                ColumnDesc::unnamed(ColumnId::new(4), DataType::Int64),
1437            ],
1438            vec![OrderType::ascending()],
1439            vec![0],
1440            1,
1441        );
1442        let progress_table =
1443            StateTable::from_table_catalog(&progress_catalog, store.clone(), None).await;
1444
1445        // Sort buffer: input columns ++ [gen, op, seq],
1446        // pk = gen + locality column + stream key + seq.
1447        let mut sort_buffer_catalog = gen_pbtable(
1448            TableId::new(3),
1449            vec![
1450                input_columns[0].clone(),
1451                input_columns[1].clone(),
1452                input_columns[2].clone(),
1453                ColumnDesc::unnamed(ColumnId::new(3), DataType::Int64),
1454                ColumnDesc::unnamed(ColumnId::new(4), DataType::Int16),
1455                ColumnDesc::unnamed(ColumnId::new(5), DataType::Int64),
1456            ],
1457            vec![OrderType::ascending(); 4],
1458            vec![3, 0, 1, 5],
1459            0,
1460        );
1461        sort_buffer_catalog.clean_watermark_indices = vec![3];
1462        let sort_buffer_table =
1463            StateTable::from_table_catalog_inconsistent_op(&sort_buffer_catalog, store, None).await;
1464
1465        let fragment_id: FragmentId = 1.into();
1466        let progress = CreateMviewProgressReporter::for_test(LocalBarrierManager::for_test());
1467        let (mut tx, source) = MockSource::channel();
1468        let upstream = source.into_executor(input_schema.clone(), vec![1]);
1469        let mut executor = LocalityProviderExecutor::new(
1470            upstream,
1471            vec![0],
1472            state_table,
1473            progress_table,
1474            Some(sort_buffer_table),
1475            SortBufferSettings {
1476                enabled: true,
1477                activate_threshold: 0,
1478            },
1479            input_schema,
1480            progress,
1481            Arc::new(StreamingMetrics::unused()),
1482            1024,
1483            fragment_id,
1484            RateLimit::Disabled,
1485        )
1486        .boxed()
1487        .execute();
1488
1489        tx.push_barrier(test_epoch(1), false);
1490        assert_eq!(
1491            executor.expect_barrier().await.epoch,
1492            EpochPair::new_test_epoch(test_epoch(1))
1493        );
1494
1495        tx.send_barrier(Barrier::new_test_barrier(test_epoch(2)).with_mutation(
1496            Mutation::StartFragmentBackfill {
1497                fragment_ids: [fragment_id].into(),
1498            },
1499        ));
1500        assert_eq!(
1501            executor.expect_barrier().await.epoch,
1502            EpochPair::new_test_epoch(test_epoch(2))
1503        );
1504
1505        // Drive the empty snapshot to EOF. The executor is now waiting for the barrier that
1506        // persists completion, and must still pass through chunks even though the threshold is 0.
1507        assert!(
1508            timeout(Duration::from_millis(200), executor.next())
1509                .await
1510                .is_err()
1511        );
1512        let transition_chunk = StreamChunk::from_pretty(
1513            " i i i
1514            + 2 1 20
1515            + 1 1 10",
1516        );
1517        tx.push_chunk(transition_chunk.clone());
1518        assert_eq!(executor.expect_chunk().await, transition_chunk);
1519
1520        tx.push_barrier(test_epoch(3), false);
1521        assert_eq!(
1522            executor.expect_barrier().await.epoch,
1523            EpochPair::new_test_epoch(test_epoch(3))
1524        );
1525
1526        // Starting with the next epoch, the same unsorted input is buffered and replayed in
1527        // locality order at the barrier.
1528        tx.push_chunk(StreamChunk::from_pretty(
1529            " i i i
1530            + 4 1 40
1531            + 3 1 30",
1532        ));
1533        tx.push_barrier(test_epoch(4), false);
1534        assert_eq!(
1535            executor.expect_chunk().await,
1536            StreamChunk::from_pretty(
1537                " i i i
1538                + 3 1 30
1539                + 4 1 40",
1540            )
1541        );
1542        assert_eq!(
1543            executor.expect_barrier().await.epoch,
1544            EpochPair::new_test_epoch(test_epoch(4))
1545        );
1546    }
1547}