Skip to main content

risingwave_stream/executor/mview/
materialize.rs

1// Copyright 2022 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::HashSet;
16use std::ops::Bound;
17
18use bytes::Bytes;
19use futures::future::Either;
20use futures::stream::{self, select_with_strategy};
21use futures_async_stream::try_stream;
22use itertools::Itertools;
23use risingwave_common::array::Op;
24use risingwave_common::bitmap::Bitmap;
25use risingwave_common::catalog::{
26    ColumnDesc, ConflictBehavior, TableId, checked_conflict_behaviors,
27};
28use risingwave_common::hash::{VirtualNode, VnodeBitmapExt};
29use risingwave_common::row::{OwnedRow, RowExt};
30use risingwave_common::types::DataType;
31use risingwave_common::util::sort_util::ColumnOrder;
32use risingwave_common::util::value_encoding::BasicSerde;
33use risingwave_pb::catalog::Table;
34use risingwave_pb::catalog::table::Engine;
35use risingwave_pb::id::{SourceId, SubscriberId};
36use risingwave_pb::stream_plan::SubscriptionUpstreamInfo;
37use risingwave_storage::row_serde::value_serde::{ValueRowSerde, ValueRowSerdeNew};
38use risingwave_storage::store::PrefetchOptions;
39use risingwave_storage::table::KeyedRow;
40
41use crate::common::change_buffer::output_kind as cb_kind;
42use crate::common::metrics::MetricsInfo;
43use crate::common::table::state_table::{
44    StateTableBuilder, StateTableInner, StateTableOpConsistencyLevel,
45};
46use crate::consistency::consistency_panic;
47use crate::executor::error::ErrorKind;
48use crate::executor::monitor::MaterializeMetrics;
49use crate::executor::mview::RefreshProgressTable;
50use crate::executor::mview::cache::{MaterializeCache, toast};
51use crate::executor::prelude::*;
52use crate::executor::{BarrierInner, BarrierMutationType};
53use crate::task::LocalBarrierManager;
54
55#[derive(Debug, Clone)]
56pub enum MaterializeStreamState<M> {
57    NormalIngestion,
58    MergingData,
59    CleanUp,
60    CommitAndYieldBarrier {
61        barrier: BarrierInner<M>,
62        expect_next_state: Box<MaterializeStreamState<M>>,
63    },
64    RefreshEnd,
65}
66
67/// `MaterializeExecutor` materializes changes in stream into a materialized view on storage.
68pub struct MaterializeExecutor<S: StateStore, SD: ValueRowSerde> {
69    input: Executor,
70
71    schema: Schema,
72
73    state_table: StateTableInner<S, SD>,
74
75    /// Stream key indices of the *output* of this materialize node.
76    ///
77    /// This can be different from the table PK. Typically, there are 3 cases:
78    ///
79    /// - Normal `TABLE`: `stream_key == pk`, so no special handling is needed.
80    /// - TTL-ed `TABLE` (`WITH TTL`): `stream_key` is a superset of `pk` by appending the TTL
81    ///   watermark column. In this case, updates to the same pk may have different stream keys, so
82    ///   we must rewrite such `Update` into `Delete + Insert` before yielding.
83    /// - `MV` or `INDEX`: `pk` can be a superset of `stream_key` to also include the order key or
84    ///   distribution key specified by the user. No special handling is needed either.
85    stream_key_indices: Vec<usize>,
86
87    /// Columns of arrange keys (including pk, group keys, join keys, etc.)
88    arrange_key_indices: Vec<usize>,
89
90    actor_context: ActorContextRef,
91
92    /// The cache for conflict handling. `None` if conflict behavior is `NoCheck`.
93    materialize_cache: Option<MaterializeCache>,
94
95    /// Indices of columns that may carry a Debezium unchanged-TOAST placeholder
96    /// (PostgreSQL CDC only). Mirrors the value passed into `MaterializeCache`.
97    /// Used at runtime to prevent the `Overwrite -> NoCheck` downgrade from
98    /// bypassing the placeholder replacement path.
99    toastable_column_indices: Option<Vec<usize>>,
100
101    conflict_behavior: ConflictBehavior,
102
103    /// Whether the table can clean itself by TTL watermark, i.e., is defined with `WATERMARK ... WITH TTL`.
104    cleaned_by_ttl_watermark: bool,
105
106    version_column_indices: Vec<u32>,
107
108    may_have_downstream: bool,
109
110    subscriber_ids: HashSet<SubscriberId>,
111
112    metrics: MaterializeMetrics,
113
114    /// No data will be written to hummock table. This Materialize is just a dummy node.
115    /// Used for APPEND ONLY table with iceberg engine. All data will be written to iceberg table directly.
116    is_dummy_table: bool,
117
118    /// Optional refresh arguments and state for refreshable materialized views
119    refresh_args: Option<RefreshableMaterializeArgs<S, SD>>,
120
121    /// Local barrier manager for reporting barrier events
122    local_barrier_manager: LocalBarrierManager,
123}
124
125/// Arguments and state for refreshable materialized views
126pub struct RefreshableMaterializeArgs<S: StateStore, SD: ValueRowSerde> {
127    /// Table catalog for main table
128    pub table_catalog: Table,
129
130    /// Table catalog for staging table
131    pub staging_table_catalog: Table,
132
133    /// During data refresh (between `RefreshStart` and `LoadFinish`),
134    /// data will be written to both the main table and the staging table.
135    ///
136    /// The staging table is PK-only.
137    ///
138    /// After `LoadFinish`, we will do a `DELETE FROM main_table WHERE pk NOT IN (SELECT pk FROM staging_table)`.
139    /// The commit of the `RefreshStart` barrier truncates the staging table, so every cycle loads
140    /// into an empty one.
141    pub staging_table: StateTableInner<S, SD>,
142
143    /// Per-vnode merge progress of the current cycle; the merge resumes from it after every barrier.
144    pub progress_table: RefreshProgressTable<S>,
145
146    /// `prev` epoch of the running cycle's `RefreshStart` barrier, `None` outside a cycle. The
147    /// staging truncation is committed at that epoch, so the merge waits for it to be visible
148    /// locally.
149    pub refresh_start_prev_epoch: Option<u64>,
150
151    /// Table ID for this refreshable materialized view
152    pub table_id: TableId,
153}
154
155impl<S: StateStore, SD: ValueRowSerde> RefreshableMaterializeArgs<S, SD> {
156    fn is_refreshing(&self) -> bool {
157        self.refresh_start_prev_epoch.is_some()
158    }
159
160    /// Create new `RefreshableMaterializeArgs`
161    pub async fn new(
162        store: S,
163        table_catalog: &Table,
164        staging_table_catalog: &Table,
165        progress_state_table: &Table,
166        vnodes: Option<Arc<Bitmap>>,
167    ) -> Self {
168        let table_id = table_catalog.id;
169
170        // staging table is pk-only, and we don't need to check value consistency
171        let staging_table = StateTableInner::from_table_catalog_inconsistent_op(
172            staging_table_catalog,
173            store.clone(),
174            vnodes.clone(),
175        )
176        .await;
177
178        let progress_state_table = StateTableInner::from_table_catalog_inconsistent_op(
179            progress_state_table,
180            store,
181            vnodes,
182        )
183        .await;
184
185        // Get primary key length from main table catalog
186        let pk_len = table_catalog.pk.len();
187        let progress_table = RefreshProgressTable::new(progress_state_table, pk_len);
188
189        debug_assert_eq!(staging_table.vnodes(), progress_table.vnodes());
190
191        Self {
192            table_catalog: table_catalog.clone(),
193            staging_table_catalog: staging_table_catalog.clone(),
194            staging_table,
195            progress_table,
196            refresh_start_prev_epoch: None,
197            table_id,
198        }
199    }
200}
201
202fn get_op_consistency_level(
203    cleaned_by_ttl_watermark: bool,
204    conflict_behavior: ConflictBehavior,
205    may_have_downstream: bool,
206    subscriber_ids: &HashSet<SubscriberId>,
207) -> StateTableOpConsistencyLevel {
208    if cleaned_by_ttl_watermark {
209        // For tables with watermark TTL. Due to async state cleaning, it's uncertain whether an expired
210        // key has been cleaned up by the storage or not, thus we are not sure if to write `Update` or `Insert`
211        // if the key appears again.
212        StateTableOpConsistencyLevel::Inconsistent
213    } else if !subscriber_ids.is_empty() {
214        StateTableOpConsistencyLevel::LogStoreEnabled
215    } else if !may_have_downstream && matches!(conflict_behavior, ConflictBehavior::Overwrite) {
216        // Table with overwrite conflict behavior could disable conflict check
217        // if no downstream mv depends on it, so we use a inconsistent_op to skip sanity check as well.
218        StateTableOpConsistencyLevel::Inconsistent
219    } else {
220        StateTableOpConsistencyLevel::ConsistentOldValue
221    }
222}
223
224impl<S: StateStore, SD: ValueRowSerde> MaterializeExecutor<S, SD> {
225    /// Create a new `MaterializeExecutor` with distribution specified with `distribution_keys` and
226    /// `vnodes`. For singleton distribution, `distribution_keys` should be empty and `vnodes`
227    /// should be `None`.
228    #[expect(clippy::too_many_arguments)]
229    pub async fn new(
230        input: Executor,
231        schema: Schema,
232        store: S,
233        arrange_key: Vec<ColumnOrder>,
234        actor_context: ActorContextRef,
235        vnodes: Option<Arc<Bitmap>>,
236        table_catalog: &Table,
237        watermark_epoch: AtomicU64Ref,
238        conflict_behavior: ConflictBehavior,
239        version_column_indices: Vec<u32>,
240        metrics: Arc<StreamingMetrics>,
241        refresh_args: Option<RefreshableMaterializeArgs<S, SD>>,
242        cleaned_by_ttl_watermark: bool,
243        local_barrier_manager: LocalBarrierManager,
244    ) -> Self {
245        let table_columns: Vec<ColumnDesc> = table_catalog
246            .columns
247            .iter()
248            .map(|col| col.column_desc.as_ref().unwrap().into())
249            .collect();
250
251        // Extract TOAST-able column indices from table columns.
252        // Only for PostgreSQL CDC tables.
253        let toastable_column_indices = if table_catalog.cdc_table_type()
254            == risingwave_pb::catalog::table::CdcTableType::Postgres
255        {
256            let toastable_indices: Vec<usize> = table_columns
257                .iter()
258                .enumerate()
259                .filter_map(|(index, column)| match &column.data_type {
260                    // Currently supports TOAST updates for:
261                    // - jsonb (DataType::Jsonb)
262                    // - varchar (DataType::Varchar)
263                    // - bytea (DataType::Bytea)
264                    // - pgvector (DataType::Vector)
265                    // - One-dimensional arrays of the above types (DataType::List)
266                    //   Note: Some array types may not be fully supported yet, see issue  https://github.com/risingwavelabs/risingwave/issues/22916 for details.
267
268                    // For details on how TOAST values are handled, see comments in `is_debezium_unavailable_value`.
269                    DataType::Varchar
270                    | DataType::List(_)
271                    | DataType::Bytea
272                    | DataType::Jsonb
273                    | DataType::Vector(_) => Some(index),
274                    _ => None,
275                })
276                .collect();
277
278            if toastable_indices.is_empty() {
279                None
280            } else {
281                Some(toastable_indices)
282            }
283        } else {
284            None
285        };
286
287        let row_serde: BasicSerde = BasicSerde::new(
288            Arc::from_iter(table_catalog.value_indices.iter().map(|val| *val as usize)),
289            Arc::from(table_columns.into_boxed_slice()),
290        );
291
292        let stream_key_indices: Vec<usize> = table_catalog
293            .stream_key
294            .iter()
295            .map(|idx| *idx as usize)
296            .collect();
297        let arrange_key_indices: Vec<usize> = arrange_key.iter().map(|k| k.column_index).collect();
298        let may_have_downstream = actor_context.initial_dispatch_num != 0;
299        let subscriber_ids = actor_context.initial_subscriber_ids.clone();
300        let op_consistency_level = get_op_consistency_level(
301            cleaned_by_ttl_watermark,
302            conflict_behavior,
303            may_have_downstream,
304            &subscriber_ids,
305        );
306        let state_table_metrics = metrics.new_state_table_metrics(
307            table_catalog.id,
308            actor_context.id,
309            actor_context.fragment_id,
310        );
311        // Note: The current implementation could potentially trigger a switch on the inconsistent_op flag. If the storage relies on this flag to perform optimizations, it would be advisable to maintain consistency with it throughout the lifecycle.
312        let state_table = StateTableBuilder::new(table_catalog, store, vnodes)
313            .with_op_consistency_level(op_consistency_level)
314            .enable_preload_all_rows_by_config(&actor_context.config)
315            .enable_vnode_key_stats(
316                actor_context
317                    .config
318                    .developer
319                    .enable_vnode_key_stats_for_materialize,
320                &actor_context.config,
321            )
322            .with_metrics(state_table_metrics)
323            .build()
324            .await;
325
326        let mv_metrics = metrics.new_materialize_metrics(
327            table_catalog.id,
328            actor_context.id,
329            actor_context.fragment_id,
330        );
331        let cache_metrics = metrics.new_materialize_cache_metrics(
332            table_catalog.id,
333            actor_context.id,
334            actor_context.fragment_id,
335        );
336
337        let metrics_info =
338            MetricsInfo::new(metrics, table_catalog.id, actor_context.id, "Materialize");
339
340        let is_dummy_table =
341            table_catalog.engine == Some(Engine::Iceberg as i32) && table_catalog.append_only;
342
343        Self {
344            input,
345            schema,
346            state_table,
347            stream_key_indices,
348            arrange_key_indices,
349            actor_context,
350            materialize_cache: MaterializeCache::new(
351                watermark_epoch,
352                metrics_info,
353                row_serde,
354                version_column_indices.clone(),
355                conflict_behavior,
356                toastable_column_indices.clone(),
357                cache_metrics,
358            ),
359            toastable_column_indices,
360            conflict_behavior,
361            cleaned_by_ttl_watermark,
362            version_column_indices,
363            is_dummy_table,
364            may_have_downstream,
365            subscriber_ids,
366            metrics: mv_metrics,
367            refresh_args,
368            local_barrier_manager,
369        }
370    }
371
372    #[try_stream(ok = Message, error = StreamExecutorError)]
373    async fn execute_inner(mut self) {
374        let mv_table_id = self.state_table.table_id();
375        let data_types = self.schema.data_types();
376        let mut input = self.input.execute();
377
378        let barrier = expect_first_barrier(&mut input).await?;
379        let first_epoch = barrier.epoch;
380        let _barrier_epoch = barrier.epoch; // Save epoch for later use (unused in normal execution)
381        // The first barrier message should be propagated.
382        yield Message::Barrier(barrier);
383        self.state_table.init_epoch(first_epoch).await?;
384
385        // default to normal ingestion
386        let mut inner_state =
387            Box::new(MaterializeStreamState::<BarrierMutationType>::NormalIngestion);
388        if let Some(ref mut refresh_args) = self.refresh_args {
389            refresh_args.staging_table.init_epoch(first_epoch).await?;
390            refresh_args.progress_table.recover(first_epoch).await?;
391
392            // A refresh interrupted by recovery is not resumed here: the staging table may be
393            // only partially loaded. Meta abandons the cycle and runs it again.
394            let progress_stats = refresh_args.progress_table.get_progress_stats();
395            if progress_stats.total_vnodes > 0 && !progress_stats.is_complete() {
396                tracing::info!(
397                    table_id = %refresh_args.table_id,
398                    completed_vnodes = progress_stats.completed_vnodes,
399                    total_vnodes = progress_stats.total_vnodes,
400                    "found progress of a refresh interrupted by recovery"
401                );
402            }
403        }
404
405        // Main execution loop: cycles through Stage 1 -> Stage 2 -> Stage 3 -> Stage 1...
406        'main_loop: loop {
407            match *inner_state {
408                MaterializeStreamState::NormalIngestion => {
409                    #[for_await]
410                    '_normal_ingest: for msg in input.by_ref() {
411                        let msg = msg?;
412                        if let Some(cache) = &mut self.materialize_cache {
413                            cache.evict();
414                        }
415
416                        match msg {
417                            Message::Watermark(w) => {
418                                if self.cleaned_by_ttl_watermark
419                                    && self.state_table.clean_watermark_index == Some(w.col_idx)
420                                {
421                                    self.state_table.update_watermark(w.val.clone());
422                                }
423                                yield Message::Watermark(w);
424                            }
425                            Message::Chunk(chunk) if self.is_dummy_table => {
426                                self.metrics
427                                    .materialize_input_row_count
428                                    .inc_by(chunk.cardinality() as u64);
429                                yield Message::Chunk(chunk);
430                            }
431                            Message::Chunk(chunk) => {
432                                self.metrics
433                                    .materialize_input_row_count
434                                    .inc_by(chunk.cardinality() as u64);
435
436                                // This is an optimization that handles conflicts only when a particular materialized view downstream has no MV dependencies.
437                                // This optimization is applied only when there is no specified version column and the is_consistent_op flag of the state table is false,
438                                // and the conflict behavior is overwrite. We can rely on the state table to overwrite the conflicting rows in the storage,
439                                // while outputting inconsistent changes to downstream which no one will subscribe to.
440                                // For tables with watermark TTL (indicated by `cleaned_by_ttl_watermark`), conflict check must be enabled.
441                                // TODO(ttl): differentiate between table consistency and downstream consistency.
442                                // When the table carries TOAST-able columns (PostgreSQL CDC), the
443                                // `MaterializeCache` is the only place where the Debezium
444                                // unchanged-TOAST placeholder is detected and swapped for the row's
445                                // previous value. Demoting to `NoCheck` would let the placeholder land
446                                // in state, so only demote chunks without any placeholder (e.g. the
447                                // snapshot rows of a CDC backfill).
448                                let optimized_conflict_behavior = if let ConflictBehavior::Overwrite =
449                                    self.conflict_behavior
450                                    && !self.state_table.is_consistent_op()
451                                    && !self.cleaned_by_ttl_watermark
452                                    && self.version_column_indices.is_empty()
453                                    && self.toastable_column_indices.as_ref().is_none_or(
454                                        |indices| {
455                                            !toast::chunk_has_unavailable_value(&chunk, indices)
456                                        },
457                                    ) {
458                                    ConflictBehavior::NoCheck
459                                } else {
460                                    self.conflict_behavior
461                                };
462
463                                match optimized_conflict_behavior {
464                                    checked_conflict_behaviors!() => {
465                                        if chunk.cardinality() == 0 {
466                                            // empty chunk
467                                            continue;
468                                        }
469
470                                        // For refreshable materialized views, write to staging table during refresh
471                                        // Do not use generate_output here.
472                                        if let Some(ref mut refresh_args) = self.refresh_args
473                                            && refresh_args.is_refreshing()
474                                        {
475                                            let key_chunk = chunk
476                                                .clone()
477                                                .project(self.state_table.pk_indices());
478                                            tracing::trace!(
479                                                staging_chunk = %key_chunk.to_pretty(),
480                                                input_chunk = %chunk.to_pretty(),
481                                                "writing to staging table"
482                                            );
483                                            if cfg!(debug_assertions) {
484                                                // refreshable source should be append-only
485                                                assert!(
486                                                    key_chunk
487                                                        .ops()
488                                                        .iter()
489                                                        .all(|op| op == &Op::Insert)
490                                                );
491                                            }
492                                            refresh_args
493                                                .staging_table
494                                                .write_chunk(key_chunk.clone());
495                                            refresh_args.staging_table.try_flush().await?;
496                                        }
497
498                                        let cache = self.materialize_cache.as_mut().unwrap();
499                                        let change_buffer =
500                                            cache.handle_new(chunk, &self.state_table).await?;
501
502                                        let output_chunk = if self.stream_key_indices
503                                            == self.state_table.pk_indices()
504                                        {
505                                            change_buffer.into_chunk::<{ cb_kind::RETRACT }>(
506                                                data_types.clone(),
507                                            )
508                                        } else {
509                                            // We only hit this branch for TTL-ed tables for now.
510                                            // Assert stream key is a superset of pk.
511                                            debug_assert!(
512                                                self.state_table
513                                                    .pk_indices()
514                                                    .iter()
515                                                    .all(|&i| self.stream_key_indices.contains(&i))
516                                            );
517                                            change_buffer.into_chunk_with_key(
518                                                data_types.clone(),
519                                                &self.stream_key_indices,
520                                            )
521                                        };
522
523                                        match output_chunk {
524                                            Some(output_chunk) => {
525                                                self.state_table.write_chunk(output_chunk.clone());
526                                                self.state_table.try_flush().await?;
527                                                yield Message::Chunk(output_chunk);
528                                            }
529                                            None => continue,
530                                        }
531                                    }
532                                    ConflictBehavior::NoCheck => {
533                                        // Demoted TOAST chunks bypass the cache: write them through so a
534                                        // later placeholder is not filled from a stale cached row.
535                                        if self.toastable_column_indices.is_some()
536                                            && let Some(cache) = &mut self.materialize_cache
537                                        {
538                                            cache.write_through(&chunk, &self.state_table);
539                                        }
540                                        self.state_table.write_chunk(chunk.clone());
541                                        self.state_table.try_flush().await?;
542
543                                        // For refreshable materialized views, also write to staging table during refresh
544                                        if let Some(ref mut refresh_args) = self.refresh_args
545                                            && refresh_args.is_refreshing()
546                                        {
547                                            let key_chunk = chunk
548                                                .clone()
549                                                .project(self.state_table.pk_indices());
550                                            tracing::trace!(
551                                                staging_chunk = %key_chunk.to_pretty(),
552                                                input_chunk = %chunk.to_pretty(),
553                                                "writing to staging table"
554                                            );
555                                            if cfg!(debug_assertions) {
556                                                // refreshable source should be append-only
557                                                assert!(
558                                                    key_chunk
559                                                        .ops()
560                                                        .iter()
561                                                        .all(|op| op == &Op::Insert)
562                                                );
563                                            }
564                                            refresh_args
565                                                .staging_table
566                                                .write_chunk(key_chunk.clone());
567                                            refresh_args.staging_table.try_flush().await?;
568                                        }
569
570                                        yield Message::Chunk(chunk);
571                                    }
572                                }
573                            }
574                            Message::Barrier(barrier) => {
575                                *inner_state = MaterializeStreamState::CommitAndYieldBarrier {
576                                    barrier,
577                                    expect_next_state: Box::new(
578                                        MaterializeStreamState::NormalIngestion,
579                                    ),
580                                };
581                                continue 'main_loop;
582                            }
583                        }
584                    }
585
586                    return Err(StreamExecutorError::from(ErrorKind::Uncategorized(
587                        anyhow::anyhow!(
588                            "Input stream terminated unexpectedly during normal ingestion"
589                        ),
590                    )));
591                }
592                MaterializeStreamState::MergingData => {
593                    let Some(refresh_args) = self.refresh_args.as_mut() else {
594                        panic!(
595                            "MaterializeExecutor entered CleanUp state without refresh_args configured"
596                        );
597                    };
598                    tracing::info!(table_id = %refresh_args.table_id, "on_load_finish: Starting table replacement operation");
599
600                    debug_assert_eq!(
601                        self.state_table.vnodes(),
602                        refresh_args.staging_table.vnodes()
603                    );
604                    debug_assert_eq!(
605                        refresh_args.staging_table.vnodes(),
606                        refresh_args.progress_table.vnodes()
607                    );
608
609                    let mut rows_to_delete = vec![];
610                    let mut merge_complete = false;
611                    let mut pending_barrier: Option<Barrier> = None;
612
613                    // Scope to limit immutable borrows to state tables
614                    {
615                        let left_input = input.by_ref().map(Either::Left);
616                        let right_merge_sort = pin!(
617                            Self::make_mergesort_stream(
618                                &self.state_table,
619                                &refresh_args.staging_table,
620                                &mut refresh_args.progress_table
621                            )
622                            .map(Either::Right)
623                        );
624
625                        // Prefer to select input stream to handle barriers promptly
626                        // Rebuild the merge stream each time processing a barrier
627                        let mut merge_stream =
628                            select_with_strategy(left_input, right_merge_sort, |_: &mut ()| {
629                                stream::PollNext::Left
630                            });
631
632                        #[for_await]
633                        'merge_stream: for either in &mut merge_stream {
634                            match either {
635                                Either::Left(msg) => {
636                                    let msg = msg?;
637                                    match msg {
638                                        Message::Watermark(w) => yield Message::Watermark(w),
639                                        Message::Chunk(chunk) => {
640                                            tracing::warn!(chunk = %chunk.to_pretty(), "chunk is ignored during merge phase");
641                                        }
642                                        Message::Barrier(b) => {
643                                            pending_barrier = Some(b);
644                                            break 'merge_stream;
645                                        }
646                                    }
647                                }
648                                Either::Right(result) => {
649                                    match result? {
650                                        Some((_vnode, row)) => {
651                                            rows_to_delete.push(row);
652                                        }
653                                        None => {
654                                            // Merge stream finished
655                                            merge_complete = true;
656
657                                            // If the merge stream finished, we need to wait for the next barrier to commit states
658                                        }
659                                    }
660                                }
661                            }
662                        }
663                    }
664
665                    // Process collected rows for deletion
666                    for row in &rows_to_delete {
667                        self.state_table.delete(row);
668                    }
669                    if let Some(cache) = &mut self.materialize_cache {
670                        cache.invalidate_rows(&rows_to_delete, &self.state_table);
671                    }
672                    if !rows_to_delete.is_empty() {
673                        let to_delete_chunk = StreamChunk::from_rows(
674                            &rows_to_delete
675                                .iter()
676                                .map(|row| (Op::Delete, row))
677                                .collect_vec(),
678                            &self.schema.data_types(),
679                        );
680
681                        yield Message::Chunk(to_delete_chunk);
682                    }
683
684                    // should wait for at least one barrier
685                    assert!(pending_barrier.is_some(), "pending barrier is not set");
686
687                    *inner_state = MaterializeStreamState::CommitAndYieldBarrier {
688                        barrier: pending_barrier.unwrap(),
689                        expect_next_state: if merge_complete {
690                            Box::new(MaterializeStreamState::CleanUp)
691                        } else {
692                            Box::new(MaterializeStreamState::MergingData)
693                        },
694                    };
695                    continue 'main_loop;
696                }
697                MaterializeStreamState::CleanUp => {
698                    let Some(refresh_args) = self.refresh_args.as_mut() else {
699                        panic!(
700                            "MaterializeExecutor entered MergingData state without refresh_args configured"
701                        );
702                    };
703                    tracing::info!(table_id = %refresh_args.table_id, "on_load_finish: resuming CleanUp Stage");
704
705                    #[for_await]
706                    for msg in input.by_ref() {
707                        let msg = msg?;
708                        match msg {
709                            Message::Watermark(w) => {
710                                if self.cleaned_by_ttl_watermark
711                                    && self.state_table.clean_watermark_index == Some(w.col_idx)
712                                {
713                                    self.state_table.update_watermark(w.val.clone());
714                                }
715                                yield Message::Watermark(w)
716                            }
717                            Message::Chunk(chunk) => {
718                                tracing::warn!(chunk = %chunk.to_pretty(), "chunk is ignored during merge phase");
719                            }
720                            Message::Barrier(barrier) if !barrier.is_checkpoint() => {
721                                *inner_state = MaterializeStreamState::CommitAndYieldBarrier {
722                                    barrier,
723                                    expect_next_state: Box::new(MaterializeStreamState::CleanUp),
724                                };
725                                continue 'main_loop;
726                            }
727                            Message::Barrier(barrier) => {
728                                self.local_barrier_manager.report_refresh_finished(
729                                    barrier.epoch,
730                                    self.actor_context.id,
731                                    refresh_args.table_id,
732                                );
733                                tracing::debug!(table_id = %refresh_args.table_id, "on_load_finish: reported refresh finished");
734
735                                *inner_state = MaterializeStreamState::CommitAndYieldBarrier {
736                                    barrier,
737                                    expect_next_state: Box::new(MaterializeStreamState::RefreshEnd),
738                                };
739                                continue 'main_loop;
740                            }
741                        }
742                    }
743                }
744                MaterializeStreamState::RefreshEnd => {
745                    let Some(refresh_args) = self.refresh_args.as_mut() else {
746                        panic!(
747                            "MaterializeExecutor entered RefreshEnd state without refresh_args configured"
748                        );
749                    };
750                    tracing::info!(table_id = %refresh_args.table_id, "RefreshEnd: Refresh completed");
751                    refresh_args.refresh_start_prev_epoch = None;
752                    *inner_state = MaterializeStreamState::NormalIngestion;
753                    continue 'main_loop;
754                }
755                MaterializeStreamState::CommitAndYieldBarrier {
756                    barrier,
757                    mut expect_next_state,
758                } => {
759                    let mut wait_staging_visible = None;
760                    if let Some(ref mut refresh_args) = self.refresh_args {
761                        match barrier.mutation.as_deref() {
762                            Some(Mutation::RefreshStart {
763                                table_id: refresh_table_id,
764                                associated_source_id: _,
765                            }) if *refresh_table_id == refresh_args.table_id => {
766                                if refresh_args.is_refreshing() {
767                                    consistency_panic!(
768                                        table_id = %refresh_table_id,
769                                        "RefreshStart received while a refresh is in progress"
770                                    );
771                                }
772                                refresh_args.refresh_start_prev_epoch = Some(barrier.epoch.prev);
773                                tracing::info!(table_id = %refresh_table_id, "RefreshStart barrier received");
774
775                                // Initialize progress tracking for all VNodes
776                                Self::init_refresh_progress(
777                                    &self.state_table,
778                                    &mut refresh_args.progress_table,
779                                    barrier.epoch.curr,
780                                )?;
781                            }
782                            Some(Mutation::LoadFinish {
783                                associated_source_id: load_finish_source_id,
784                            }) => {
785                                // Get associated source id from table catalog
786                                let associated_source_id: SourceId = match refresh_args
787                                    .table_catalog
788                                    .optional_associated_source_id
789                                {
790                                    Some(id) => id.into(),
791                                    None => unreachable!("associated_source_id is not set"),
792                                };
793
794                                if *load_finish_source_id == associated_source_id {
795                                    if !refresh_args.is_refreshing() {
796                                        // Merging now would run against an empty staging table.
797                                        consistency_panic!(
798                                            table_id = %refresh_args.table_id,
799                                            "LoadFinish received without a refresh in progress"
800                                        );
801                                    } else {
802                                        tracing::info!(
803                                            %load_finish_source_id,
804                                            "LoadFinish received, starting data replacement"
805                                        );
806                                        *expect_next_state =
807                                            MaterializeStreamState::<_>::MergingData;
808                                        wait_staging_visible =
809                                            refresh_args.refresh_start_prev_epoch;
810                                    }
811                                }
812                            }
813                            _ => {}
814                        }
815                    }
816
817                    // ===== normal operation =====
818
819                    // If a downstream mv depends on the current table, we need to do conflict check again.
820                    if !self.may_have_downstream
821                        && barrier.has_more_downstream_fragments(self.actor_context.id)
822                    {
823                        self.may_have_downstream = true;
824                    }
825                    Self::may_update_depended_subscriptions(
826                        &mut self.subscriber_ids,
827                        &barrier,
828                        mv_table_id,
829                    );
830                    let op_consistency_level = get_op_consistency_level(
831                        self.cleaned_by_ttl_watermark,
832                        self.conflict_behavior,
833                        self.may_have_downstream,
834                        &self.subscriber_ids,
835                    );
836                    let post_commit = self
837                        .state_table
838                        .commit_may_switch_consistent_op(barrier.epoch, op_consistency_level)
839                        .await?;
840
841                    let update_vnode_bitmap = barrier.as_update_vnode_bitmap(self.actor_context.id);
842
843                    // Commit staging table for refreshable materialized views
844                    let refresh_post_commit = if let Some(ref mut refresh_args) = self.refresh_args
845                    {
846                        Some((
847                            refresh_args.staging_table.commit(barrier.epoch).await?,
848                            refresh_args.progress_table.commit(barrier.epoch).await?,
849                        ))
850                    } else {
851                        None
852                    };
853
854                    let b_epoch = barrier.epoch;
855                    yield Message::Barrier(barrier);
856
857                    // Update the vnode bitmap for the state table if asked.
858                    if let Some((_, cache_may_stale)) = post_commit
859                        .post_yield_barrier(update_vnode_bitmap.clone())
860                        .await?
861                        && cache_may_stale
862                        && let Some(cache) = &mut self.materialize_cache
863                    {
864                        cache.clear();
865                    }
866
867                    // Handle staging table post commit
868                    if let Some((staging_post_commit, progress_post_commit)) = refresh_post_commit {
869                        staging_post_commit
870                            .post_yield_barrier(update_vnode_bitmap.clone())
871                            .await?;
872                        progress_post_commit
873                            .post_yield_barrier(update_vnode_bitmap)
874                            .await?;
875                    }
876
877                    if let (Some(epoch), Some(refresh_args)) =
878                        (wait_staging_visible, self.refresh_args.as_ref())
879                    {
880                        // The truncation of the staging table at the start of the cycle must be
881                        // visible locally before the merge reads it.
882                        refresh_args
883                            .staging_table
884                            .try_wait_committed_epoch(epoch)
885                            .await?;
886                    }
887
888                    self.metrics
889                        .materialize_current_epoch
890                        .set(b_epoch.curr as i64);
891
892                    // ====== transition to next state ======
893
894                    *inner_state = *expect_next_state;
895                }
896            }
897        }
898    }
899
900    /// Stream that yields rows to be deleted from main table.
901    /// Yields `Some((vnode, row))` for rows that exist in main but not in staging.
902    /// Yields `None` when finished processing all vnodes.
903    #[try_stream(ok = Option<(VirtualNode, OwnedRow)>, error = StreamExecutorError)]
904    async fn make_mergesort_stream<'a>(
905        main_table: &'a StateTableInner<S, SD>,
906        staging_table: &'a StateTableInner<S, SD>,
907        progress_table: &'a mut RefreshProgressTable<S>,
908    ) {
909        for vnode in main_table.vnodes().clone().iter_vnodes() {
910            let mut processed_rows = 0;
911            // The merge is rebuilt after every barrier and resumes from the recorded position.
912            let pk_range: (Bound<OwnedRow>, Bound<OwnedRow>) =
913                if let Some(current_entry) = progress_table.get_progress(vnode) {
914                    if current_entry.is_completed {
915                        tracing::debug!(vnode = vnode.to_index(), "vnode already merged");
916                        continue;
917                    }
918                    processed_rows += current_entry.processed_rows;
919                    tracing::debug!(vnode = vnode.to_index(), "Started merging VNode");
920
921                    if let Some(current_state) = &current_entry.current_pos {
922                        (Bound::Excluded(current_state.clone()), Bound::Unbounded)
923                    } else {
924                        (Bound::Unbounded, Bound::Unbounded)
925                    }
926                } else {
927                    (Bound::Unbounded, Bound::Unbounded)
928                };
929
930            let iter_main = main_table
931                .iter_keyed_row_with_vnode(
932                    vnode,
933                    &pk_range,
934                    PrefetchOptions::prefetch_for_large_range_scan(),
935                )
936                .await?;
937            let iter_staging = staging_table
938                .iter_keyed_row_with_vnode(
939                    vnode,
940                    &pk_range,
941                    PrefetchOptions::prefetch_for_large_range_scan(),
942                )
943                .await?;
944
945            pin_mut!(iter_main);
946            pin_mut!(iter_staging);
947
948            // Sort-merge join implementation using dual pointers
949            let mut main_item: Option<KeyedRow<Bytes>> = iter_main.next().await.transpose()?;
950            let mut staging_item: Option<KeyedRow<Bytes>> =
951                iter_staging.next().await.transpose()?;
952
953            while let Some(main_kv) = main_item {
954                let main_key = main_kv.key();
955
956                // Advance staging iterator until we find a key >= main_key
957                let mut should_delete = false;
958                while let Some(staging_kv) = &staging_item {
959                    let staging_key = staging_kv.key();
960                    match main_key.cmp(staging_key) {
961                        std::cmp::Ordering::Greater => {
962                            // main_key > staging_key, advance staging
963                            staging_item = iter_staging.next().await.transpose()?;
964                        }
965                        std::cmp::Ordering::Equal => {
966                            // Keys match, this row exists in both tables, no need to delete
967                            break;
968                        }
969                        std::cmp::Ordering::Less => {
970                            // main_key < staging_key, main row doesn't exist in staging, delete it
971                            should_delete = true;
972                            break;
973                        }
974                    }
975                }
976
977                // If staging_item is None, all remaining main rows should be deleted
978                if staging_item.is_none() {
979                    should_delete = true;
980                }
981
982                if should_delete {
983                    yield Some((vnode, main_kv.row().clone()));
984                }
985
986                // Advance main iterator
987                processed_rows += 1;
988                tracing::debug!(
989                    "set progress table: vnode = {:?}, processed_rows = {:?}",
990                    vnode,
991                    processed_rows
992                );
993                progress_table.set_progress(
994                    vnode,
995                    Some(
996                        main_kv
997                            .row()
998                            .project(main_table.pk_indices())
999                            .to_owned_row(),
1000                    ),
1001                    false,
1002                    processed_rows,
1003                )?;
1004                main_item = iter_main.next().await.transpose()?;
1005            }
1006
1007            // Mark this VNode as completed
1008            if let Some(current_entry) = progress_table.get_progress(vnode) {
1009                progress_table.set_progress(
1010                    vnode,
1011                    current_entry.current_pos.clone(),
1012                    true, // completed
1013                    current_entry.processed_rows,
1014                )?;
1015
1016                tracing::debug!(vnode = vnode.to_index(), "Completed merging VNode");
1017            }
1018        }
1019
1020        // Signal completion
1021        yield None;
1022    }
1023
1024    /// return true when changed
1025    fn may_update_depended_subscriptions(
1026        depended_subscriptions: &mut HashSet<SubscriberId>,
1027        barrier: &Barrier,
1028        mv_table_id: TableId,
1029    ) {
1030        for subscriber_id in barrier.added_subscriber_on_mv_table(mv_table_id) {
1031            if !depended_subscriptions.insert(subscriber_id) {
1032                warn!(
1033                    ?depended_subscriptions,
1034                    %mv_table_id,
1035                    %subscriber_id,
1036                    "subscription id already exists"
1037                );
1038            }
1039        }
1040
1041        if let Some(subscriptions_to_drop) = barrier.as_subscriptions_to_drop() {
1042            for SubscriptionUpstreamInfo {
1043                subscriber_id,
1044                upstream_mv_table_id,
1045            } in subscriptions_to_drop
1046            {
1047                if *upstream_mv_table_id == mv_table_id
1048                    && !depended_subscriptions.remove(subscriber_id)
1049                {
1050                    warn!(
1051                        ?depended_subscriptions,
1052                        %mv_table_id,
1053                        %subscriber_id,
1054                        "drop non existing subscriber_id id"
1055                    );
1056                }
1057            }
1058        }
1059    }
1060
1061    /// Initialize refresh progress tracking for all `VNodes`
1062    fn init_refresh_progress(
1063        state_table: &StateTableInner<S, SD>,
1064        progress_table: &mut RefreshProgressTable<S>,
1065        _epoch: u64,
1066    ) -> StreamExecutorResult<()> {
1067        debug_assert_eq!(state_table.vnodes(), progress_table.vnodes());
1068
1069        // Initialize progress for all VNodes in the current bitmap
1070        for vnode in state_table.vnodes().iter_vnodes() {
1071            progress_table.set_progress(
1072                vnode, None,  // initial position
1073                false, // not completed yet
1074                0,     // initial processed rows
1075            )?;
1076        }
1077
1078        tracing::info!(
1079            vnodes_count = state_table.vnodes().count_ones(),
1080            "Initialized refresh progress tracking for all VNodes"
1081        );
1082
1083        Ok(())
1084    }
1085}
1086
1087impl<S: StateStore> MaterializeExecutor<S, BasicSerde> {
1088    /// Create a new `MaterializeExecutor` without distribution info for test purpose.
1089    #[cfg(any(test, feature = "test"))]
1090    pub async fn for_test(
1091        input: Executor,
1092        store: S,
1093        table_id: TableId,
1094        keys: Vec<ColumnOrder>,
1095        column_ids: Vec<risingwave_common::catalog::ColumnId>,
1096        watermark_epoch: AtomicU64Ref,
1097        conflict_behavior: ConflictBehavior,
1098    ) -> Self {
1099        Self::for_test_inner(
1100            input,
1101            store,
1102            table_id,
1103            keys,
1104            column_ids,
1105            watermark_epoch,
1106            conflict_behavior,
1107            None,
1108            None,
1109            None,
1110            true,
1111        )
1112        .await
1113    }
1114
1115    #[cfg(any(test, feature = "test"))]
1116    #[expect(clippy::too_many_arguments)]
1117    pub async fn for_test_with_stream_key(
1118        input: Executor,
1119        store: S,
1120        table_id: TableId,
1121        keys: Vec<ColumnOrder>,
1122        stream_key: Vec<usize>,
1123        column_ids: Vec<risingwave_common::catalog::ColumnId>,
1124        watermark_epoch: AtomicU64Ref,
1125        conflict_behavior: ConflictBehavior,
1126    ) -> Self {
1127        Self::for_test_inner(
1128            input,
1129            store,
1130            table_id,
1131            keys,
1132            column_ids,
1133            watermark_epoch,
1134            conflict_behavior,
1135            Some(stream_key),
1136            None,
1137            None,
1138            true,
1139        )
1140        .await
1141    }
1142
1143    /// Create a TTL-ed `MaterializeExecutor` cleaned by the watermark of `ttl_column`, which
1144    /// is appended to the stream key, for test purpose.
1145    #[cfg(any(test, feature = "test"))]
1146    #[expect(clippy::too_many_arguments)]
1147    pub async fn for_test_with_ttl(
1148        input: Executor,
1149        store: S,
1150        table_id: TableId,
1151        keys: Vec<ColumnOrder>,
1152        column_ids: Vec<risingwave_common::catalog::ColumnId>,
1153        watermark_epoch: AtomicU64Ref,
1154        conflict_behavior: ConflictBehavior,
1155        ttl_column: usize,
1156    ) -> Self {
1157        let stream_key = keys
1158            .iter()
1159            .map(|k| k.column_index)
1160            .chain(std::iter::once(ttl_column))
1161            .collect();
1162        Self::for_test_inner(
1163            input,
1164            store,
1165            table_id,
1166            keys,
1167            column_ids,
1168            watermark_epoch,
1169            conflict_behavior,
1170            Some(stream_key),
1171            Some(ttl_column),
1172            None,
1173            true,
1174        )
1175        .await
1176    }
1177
1178    /// Create a `MaterializeExecutor` of a PostgreSQL CDC table with TOAST-able columns and no
1179    /// downstream, for test purpose.
1180    #[cfg(any(test, feature = "test"))]
1181    pub async fn for_test_with_toast(
1182        input: Executor,
1183        store: S,
1184        table_id: TableId,
1185        keys: Vec<ColumnOrder>,
1186        column_ids: Vec<risingwave_common::catalog::ColumnId>,
1187        toastable_column_indices: Vec<usize>,
1188    ) -> Self {
1189        Self::for_test_inner(
1190            input,
1191            store,
1192            table_id,
1193            keys,
1194            column_ids,
1195            Arc::new(std::sync::atomic::AtomicU64::new(0)),
1196            ConflictBehavior::Overwrite,
1197            None,
1198            None,
1199            Some(toastable_column_indices),
1200            false,
1201        )
1202        .await
1203    }
1204
1205    #[cfg(any(test, feature = "test"))]
1206    #[expect(clippy::too_many_arguments)]
1207    async fn for_test_inner(
1208        input: Executor,
1209        store: S,
1210        table_id: TableId,
1211        keys: Vec<ColumnOrder>,
1212        column_ids: Vec<risingwave_common::catalog::ColumnId>,
1213        watermark_epoch: AtomicU64Ref,
1214        conflict_behavior: ConflictBehavior,
1215        stream_key: Option<Vec<usize>>,
1216        ttl_column: Option<usize>,
1217        toastable_column_indices: Option<Vec<usize>>,
1218        may_have_downstream: bool,
1219    ) -> Self {
1220        use risingwave_common::util::iter_util::ZipEqFast;
1221
1222        let arrange_columns: Vec<usize> = keys.iter().map(|k| k.column_index).collect();
1223        let arrange_order_types = keys.iter().map(|k| k.order_type).collect();
1224        let schema = input.schema().clone();
1225        let columns: Vec<ColumnDesc> = column_ids
1226            .into_iter()
1227            .zip_eq_fast(schema.fields.iter())
1228            .map(|(column_id, field)| ColumnDesc::unnamed(column_id, field.data_type()))
1229            .collect_vec();
1230
1231        let row_serde = BasicSerde::new(
1232            Arc::from((0..columns.len()).collect_vec()),
1233            Arc::from(columns.clone().into_boxed_slice()),
1234        );
1235        let stream_key_indices = stream_key.unwrap_or_else(|| arrange_columns.clone());
1236        let mut table_catalog = crate::common::table::test_utils::gen_pbtable(
1237            table_id,
1238            columns,
1239            arrange_order_types,
1240            arrange_columns.clone(),
1241            0,
1242        );
1243        table_catalog.stream_key = stream_key_indices.iter().map(|i| *i as i32).collect();
1244        if let Some(ttl_column) = ttl_column {
1245            table_catalog.clean_watermark_indices = vec![ttl_column as u32];
1246        }
1247        let op_consistency_level = get_op_consistency_level(
1248            ttl_column.is_some(),
1249            conflict_behavior,
1250            may_have_downstream,
1251            &HashSet::new(),
1252        );
1253        let state_table = if op_consistency_level == StateTableOpConsistencyLevel::Inconsistent {
1254            StateTableInner::from_table_catalog_inconsistent_op(&table_catalog, store, None).await
1255        } else {
1256            StateTableInner::from_table_catalog(&table_catalog, store, None).await
1257        };
1258
1259        let unused = StreamingMetrics::unused();
1260        let metrics = unused.new_materialize_metrics(table_id, 1.into(), 2.into());
1261        let cache_metrics = unused.new_materialize_cache_metrics(table_id, 1.into(), 2.into());
1262
1263        Self {
1264            input,
1265            schema,
1266            state_table,
1267            stream_key_indices,
1268            arrange_key_indices: arrange_columns.clone(),
1269            actor_context: ActorContext::for_test(0),
1270            materialize_cache: MaterializeCache::new(
1271                watermark_epoch,
1272                MetricsInfo::for_test(),
1273                row_serde,
1274                vec![],
1275                conflict_behavior,
1276                toastable_column_indices.clone(),
1277                cache_metrics,
1278            ),
1279            toastable_column_indices,
1280            conflict_behavior,
1281            cleaned_by_ttl_watermark: ttl_column.is_some(),
1282            version_column_indices: vec![],
1283            is_dummy_table: false,
1284            may_have_downstream,
1285            subscriber_ids: HashSet::new(),
1286            metrics,
1287            refresh_args: None, // Test constructor doesn't support refresh functionality
1288            local_barrier_manager: LocalBarrierManager::for_test(),
1289        }
1290    }
1291}
1292
1293impl<S: StateStore, SD: ValueRowSerde> Execute for MaterializeExecutor<S, SD> {
1294    fn execute(self: Box<Self>) -> BoxedMessageStream {
1295        self.execute_inner().boxed()
1296    }
1297}
1298
1299impl<S: StateStore, SD: ValueRowSerde> std::fmt::Debug for MaterializeExecutor<S, SD> {
1300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1301        f.debug_struct("MaterializeExecutor")
1302            .field("arrange_key_indices", &self.arrange_key_indices)
1303            .field("stream_key_indices", &self.stream_key_indices)
1304            .finish()
1305    }
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310
1311    use std::iter;
1312    use std::sync::atomic::AtomicU64;
1313
1314    use rand::rngs::SmallRng;
1315    use rand::{Rng, RngCore, SeedableRng};
1316    use risingwave_common::array::stream_chunk::{StreamChunkMut, StreamChunkTestExt};
1317    use risingwave_common::catalog::Field;
1318    use risingwave_common::util::epoch::test_epoch;
1319    use risingwave_common::util::sort_util::OrderType;
1320    use risingwave_hummock_sdk::HummockReadEpoch;
1321    use risingwave_storage::memory::MemoryStateStore;
1322    use risingwave_storage::table::batch_table::BatchTable;
1323
1324    use super::*;
1325    use crate::executor::test_utils::*;
1326
1327    #[tokio::test]
1328    async fn test_materialize_executor() {
1329        // Prepare storage and memtable.
1330        let memory_state_store = MemoryStateStore::new();
1331        let table_id = TableId::new(1);
1332        // Two columns of int32 type, the first column is PK.
1333        let schema = Schema::new(vec![
1334            Field::unnamed(DataType::Int32),
1335            Field::unnamed(DataType::Int32),
1336        ]);
1337        let column_ids = vec![0.into(), 1.into()];
1338
1339        // Prepare source chunks.
1340        let chunk1 = StreamChunk::from_pretty(
1341            " i i
1342            + 1 4
1343            + 2 5
1344            + 3 6",
1345        );
1346        let chunk2 = StreamChunk::from_pretty(
1347            " i i
1348            + 7 8
1349            - 3 6",
1350        );
1351
1352        // Prepare stream executors.
1353        let source = MockSource::with_messages(vec![
1354            Message::Barrier(Barrier::new_test_barrier(test_epoch(1))),
1355            Message::Chunk(chunk1),
1356            Message::Barrier(Barrier::new_test_barrier(test_epoch(2))),
1357            Message::Chunk(chunk2),
1358            Message::Barrier(Barrier::new_test_barrier(test_epoch(3))),
1359        ])
1360        .into_executor(schema.clone(), StreamKey::new());
1361
1362        let order_types = vec![OrderType::ascending()];
1363        let column_descs = vec![
1364            ColumnDesc::unnamed(column_ids[0], DataType::Int32),
1365            ColumnDesc::unnamed(column_ids[1], DataType::Int32),
1366        ];
1367
1368        let table = BatchTable::for_test(
1369            memory_state_store.clone(),
1370            table_id,
1371            column_descs,
1372            order_types,
1373            vec![0],
1374            vec![0, 1],
1375        );
1376
1377        let mut materialize_executor = MaterializeExecutor::for_test(
1378            source,
1379            memory_state_store,
1380            table_id,
1381            vec![ColumnOrder::new(0, OrderType::ascending())],
1382            column_ids,
1383            Arc::new(AtomicU64::new(0)),
1384            ConflictBehavior::NoCheck,
1385        )
1386        .await
1387        .boxed()
1388        .execute();
1389        materialize_executor.next().await.transpose().unwrap();
1390
1391        materialize_executor.next().await.transpose().unwrap();
1392
1393        // First stream chunk. We check the existence of (3) -> (3,6)
1394        match materialize_executor.next().await.transpose().unwrap() {
1395            Some(Message::Barrier(_)) => {
1396                let row = table
1397                    .get_row(
1398                        &OwnedRow::new(vec![Some(3_i32.into())]),
1399                        HummockReadEpoch::NoWait(u64::MAX),
1400                    )
1401                    .await
1402                    .unwrap();
1403                assert_eq!(
1404                    row,
1405                    Some(OwnedRow::new(vec![Some(3_i32.into()), Some(6_i32.into())]))
1406                );
1407            }
1408            _ => unreachable!(),
1409        }
1410        materialize_executor.next().await.transpose().unwrap();
1411        // Second stream chunk. We check the existence of (7) -> (7,8)
1412        match materialize_executor.next().await.transpose().unwrap() {
1413            Some(Message::Barrier(_)) => {
1414                let row = table
1415                    .get_row(
1416                        &OwnedRow::new(vec![Some(7_i32.into())]),
1417                        HummockReadEpoch::NoWait(u64::MAX),
1418                    )
1419                    .await
1420                    .unwrap();
1421                assert_eq!(
1422                    row,
1423                    Some(OwnedRow::new(vec![Some(7_i32.into()), Some(8_i32.into())]))
1424                );
1425            }
1426            _ => unreachable!(),
1427        }
1428    }
1429
1430    // https://github.com/risingwavelabs/risingwave/issues/13346
1431    #[tokio::test]
1432    async fn test_upsert_stream() {
1433        // Prepare storage and memtable.
1434        let memory_state_store = MemoryStateStore::new();
1435        let table_id = TableId::new(1);
1436        // Two columns of int32 type, the first column is PK.
1437        let schema = Schema::new(vec![
1438            Field::unnamed(DataType::Int32),
1439            Field::unnamed(DataType::Int32),
1440        ]);
1441        let column_ids = vec![0.into(), 1.into()];
1442
1443        // test double insert one pk, the latter needs to override the former.
1444        let chunk1 = StreamChunk::from_pretty(
1445            " i i
1446            + 1 1",
1447        );
1448
1449        let chunk2 = StreamChunk::from_pretty(
1450            " i i
1451            + 1 2
1452            - 1 2",
1453        );
1454
1455        // Prepare stream executors.
1456        let source = MockSource::with_messages(vec![
1457            Message::Barrier(Barrier::new_test_barrier(test_epoch(1))),
1458            Message::Chunk(chunk1),
1459            Message::Barrier(Barrier::new_test_barrier(test_epoch(2))),
1460            Message::Chunk(chunk2),
1461            Message::Barrier(Barrier::new_test_barrier(test_epoch(3))),
1462        ])
1463        .into_executor(schema.clone(), StreamKey::new());
1464
1465        let order_types = vec![OrderType::ascending()];
1466        let column_descs = vec![
1467            ColumnDesc::unnamed(column_ids[0], DataType::Int32),
1468            ColumnDesc::unnamed(column_ids[1], DataType::Int32),
1469        ];
1470
1471        let table = BatchTable::for_test(
1472            memory_state_store.clone(),
1473            table_id,
1474            column_descs,
1475            order_types,
1476            vec![0],
1477            vec![0, 1],
1478        );
1479
1480        let mut materialize_executor = MaterializeExecutor::for_test(
1481            source,
1482            memory_state_store,
1483            table_id,
1484            vec![ColumnOrder::new(0, OrderType::ascending())],
1485            column_ids,
1486            Arc::new(AtomicU64::new(0)),
1487            ConflictBehavior::Overwrite,
1488        )
1489        .await
1490        .boxed()
1491        .execute();
1492        materialize_executor.next().await.transpose().unwrap();
1493
1494        materialize_executor.next().await.transpose().unwrap();
1495        materialize_executor.next().await.transpose().unwrap();
1496        materialize_executor.next().await.transpose().unwrap();
1497
1498        match materialize_executor.next().await.transpose().unwrap() {
1499            Some(Message::Barrier(_)) => {
1500                let row = table
1501                    .get_row(
1502                        &OwnedRow::new(vec![Some(1_i32.into())]),
1503                        HummockReadEpoch::NoWait(u64::MAX),
1504                    )
1505                    .await
1506                    .unwrap();
1507                assert!(row.is_none());
1508            }
1509            _ => unreachable!(),
1510        }
1511    }
1512
1513    #[tokio::test]
1514    async fn test_toast_with_skipped_conflict_check() {
1515        use risingwave_common::types::DEBEZIUM_UNAVAILABLE_VALUE as TOAST;
1516
1517        let memory_state_store = MemoryStateStore::new();
1518        let table_id = TableId::new(1);
1519        let schema = Schema::new(vec![
1520            Field::unnamed(DataType::Int32),
1521            Field::unnamed(DataType::Int32),
1522            Field::unnamed(DataType::Varchar),
1523        ]);
1524        let column_ids = vec![0.into(), 1.into(), 2.into()];
1525
1526        // Each chunk is followed by a barrier to check the state after it.
1527        let chunks = [
1528            // No placeholder: conflict check skipped.
1529            " i i T
1530            + 1 1 v1",
1531            // Placeholder: filled from state, the row is cached.
1532            &format!(
1533                " i i T
1534                + 1 2 {TOAST}"
1535            ),
1536            // No placeholder: conflict check skipped, the cached row must be updated.
1537            " i i T
1538            + 1 3 v2",
1539            // Placeholder: must be filled with v2, not the stale cached v1.
1540            &format!(
1541                " i i T
1542                + 1 4 {TOAST}"
1543            ),
1544        ];
1545        let mut messages = vec![Message::Barrier(Barrier::new_test_barrier(test_epoch(1)))];
1546        for (i, chunk) in chunks.iter().enumerate() {
1547            messages.push(Message::Chunk(StreamChunk::from_pretty(chunk)));
1548            messages.push(Message::Barrier(Barrier::new_test_barrier(test_epoch(
1549                i as u64 + 2,
1550            ))));
1551        }
1552        let source = MockSource::with_messages(messages).into_executor(schema, StreamKey::new());
1553
1554        let table = BatchTable::for_test(
1555            memory_state_store.clone(),
1556            table_id,
1557            vec![
1558                ColumnDesc::unnamed(column_ids[0], DataType::Int32),
1559                ColumnDesc::unnamed(column_ids[1], DataType::Int32),
1560                ColumnDesc::unnamed(column_ids[2], DataType::Varchar),
1561            ],
1562            vec![OrderType::ascending()],
1563            vec![0],
1564            vec![0, 1, 2],
1565        );
1566
1567        let mut materialize_executor = MaterializeExecutor::for_test_with_toast(
1568            source,
1569            memory_state_store,
1570            table_id,
1571            vec![ColumnOrder::new(0, OrderType::ascending())],
1572            column_ids,
1573            vec![2],
1574        )
1575        .await
1576        .boxed()
1577        .execute();
1578        materialize_executor.next().await.transpose().unwrap();
1579
1580        for (v, toast) in [(1, "v1"), (2, "v1"), (3, "v2"), (4, "v2")] {
1581            // Skip the output chunk, if any.
1582            while !matches!(
1583                materialize_executor.next().await.transpose().unwrap(),
1584                Some(Message::Barrier(_))
1585            ) {}
1586            let row = table
1587                .get_row(
1588                    &OwnedRow::new(vec![Some(1_i32.into())]),
1589                    HummockReadEpoch::NoWait(u64::MAX),
1590                )
1591                .await
1592                .unwrap();
1593            assert_eq!(
1594                row,
1595                Some(OwnedRow::new(vec![
1596                    Some(1_i32.into()),
1597                    Some(v.into()),
1598                    Some(toast.into())
1599                ]))
1600            );
1601        }
1602    }
1603
1604    #[tokio::test]
1605    async fn test_check_insert_conflict() {
1606        // Prepare storage and memtable.
1607        let memory_state_store = MemoryStateStore::new();
1608        let table_id = TableId::new(1);
1609        // Two columns of int32 type, the first column is PK.
1610        let schema = Schema::new(vec![
1611            Field::unnamed(DataType::Int32),
1612            Field::unnamed(DataType::Int32),
1613        ]);
1614        let column_ids = vec![0.into(), 1.into()];
1615
1616        // test double insert one pk, the latter needs to override the former.
1617        let chunk1 = StreamChunk::from_pretty(
1618            " i i
1619            + 1 3
1620            + 1 4
1621            + 2 5
1622            + 3 6",
1623        );
1624
1625        let chunk2 = StreamChunk::from_pretty(
1626            " i i
1627            + 1 3
1628            + 2 6",
1629        );
1630
1631        // test delete wrong value, delete inexistent pk
1632        let chunk3 = StreamChunk::from_pretty(
1633            " i i
1634            + 1 4",
1635        );
1636
1637        // Prepare stream executors.
1638        let source = MockSource::with_messages(vec![
1639            Message::Barrier(Barrier::new_test_barrier(test_epoch(1))),
1640            Message::Chunk(chunk1),
1641            Message::Chunk(chunk2),
1642            Message::Barrier(Barrier::new_test_barrier(test_epoch(2))),
1643            Message::Chunk(chunk3),
1644            Message::Barrier(Barrier::new_test_barrier(test_epoch(3))),
1645        ])
1646        .into_executor(schema.clone(), StreamKey::new());
1647
1648        let order_types = vec![OrderType::ascending()];
1649        let column_descs = vec![
1650            ColumnDesc::unnamed(column_ids[0], DataType::Int32),
1651            ColumnDesc::unnamed(column_ids[1], DataType::Int32),
1652        ];
1653
1654        let table = BatchTable::for_test(
1655            memory_state_store.clone(),
1656            table_id,
1657            column_descs,
1658            order_types,
1659            vec![0],
1660            vec![0, 1],
1661        );
1662
1663        let mut materialize_executor = MaterializeExecutor::for_test(
1664            source,
1665            memory_state_store,
1666            table_id,
1667            vec![ColumnOrder::new(0, OrderType::ascending())],
1668            column_ids,
1669            Arc::new(AtomicU64::new(0)),
1670            ConflictBehavior::Overwrite,
1671        )
1672        .await
1673        .boxed()
1674        .execute();
1675        materialize_executor.next().await.transpose().unwrap();
1676
1677        materialize_executor.next().await.transpose().unwrap();
1678        materialize_executor.next().await.transpose().unwrap();
1679
1680        // First stream chunk. We check the existence of (3) -> (3,6)
1681        match materialize_executor.next().await.transpose().unwrap() {
1682            Some(Message::Barrier(_)) => {
1683                let row = table
1684                    .get_row(
1685                        &OwnedRow::new(vec![Some(3_i32.into())]),
1686                        HummockReadEpoch::NoWait(u64::MAX),
1687                    )
1688                    .await
1689                    .unwrap();
1690                assert_eq!(
1691                    row,
1692                    Some(OwnedRow::new(vec![Some(3_i32.into()), Some(6_i32.into())]))
1693                );
1694
1695                let row = table
1696                    .get_row(
1697                        &OwnedRow::new(vec![Some(1_i32.into())]),
1698                        HummockReadEpoch::NoWait(u64::MAX),
1699                    )
1700                    .await
1701                    .unwrap();
1702                assert_eq!(
1703                    row,
1704                    Some(OwnedRow::new(vec![Some(1_i32.into()), Some(3_i32.into())]))
1705                );
1706
1707                let row = table
1708                    .get_row(
1709                        &OwnedRow::new(vec![Some(2_i32.into())]),
1710                        HummockReadEpoch::NoWait(u64::MAX),
1711                    )
1712                    .await
1713                    .unwrap();
1714                assert_eq!(
1715                    row,
1716                    Some(OwnedRow::new(vec![Some(2_i32.into()), Some(6_i32.into())]))
1717                );
1718            }
1719            _ => unreachable!(),
1720        }
1721    }
1722
1723    #[tokio::test]
1724    async fn test_delete_and_update_conflict() {
1725        // Prepare storage and memtable.
1726        let memory_state_store = MemoryStateStore::new();
1727        let table_id = TableId::new(1);
1728        // Two columns of int32 type, the first column is PK.
1729        let schema = Schema::new(vec![
1730            Field::unnamed(DataType::Int32),
1731            Field::unnamed(DataType::Int32),
1732        ]);
1733        let column_ids = vec![0.into(), 1.into()];
1734
1735        // test double insert one pk, the latter needs to override the former.
1736        let chunk1 = StreamChunk::from_pretty(
1737            " i i
1738            + 1 4
1739            + 2 5
1740            + 3 6
1741            U- 8 1
1742            U+ 8 2
1743            + 8 3",
1744        );
1745
1746        // test delete wrong value, delete inexistent pk
1747        let chunk2 = StreamChunk::from_pretty(
1748            " i i
1749            + 7 8
1750            - 3 4
1751            - 5 0",
1752        );
1753
1754        // test delete wrong value, delete inexistent pk
1755        let chunk3 = StreamChunk::from_pretty(
1756            " i i
1757            + 1 5
1758            U- 2 4
1759            U+ 2 8
1760            U- 9 0
1761            U+ 9 1",
1762        );
1763
1764        // Prepare stream executors.
1765        let source = MockSource::with_messages(vec![
1766            Message::Barrier(Barrier::new_test_barrier(test_epoch(1))),
1767            Message::Chunk(chunk1),
1768            Message::Barrier(Barrier::new_test_barrier(test_epoch(2))),
1769            Message::Chunk(chunk2),
1770            Message::Barrier(Barrier::new_test_barrier(test_epoch(3))),
1771            Message::Chunk(chunk3),
1772            Message::Barrier(Barrier::new_test_barrier(test_epoch(4))),
1773        ])
1774        .into_executor(schema.clone(), StreamKey::new());
1775
1776        let order_types = vec![OrderType::ascending()];
1777        let column_descs = vec![
1778            ColumnDesc::unnamed(column_ids[0], DataType::Int32),
1779            ColumnDesc::unnamed(column_ids[1], DataType::Int32),
1780        ];
1781
1782        let table = BatchTable::for_test(
1783            memory_state_store.clone(),
1784            table_id,
1785            column_descs,
1786            order_types,
1787            vec![0],
1788            vec![0, 1],
1789        );
1790
1791        let mut materialize_executor = MaterializeExecutor::for_test(
1792            source,
1793            memory_state_store,
1794            table_id,
1795            vec![ColumnOrder::new(0, OrderType::ascending())],
1796            column_ids,
1797            Arc::new(AtomicU64::new(0)),
1798            ConflictBehavior::Overwrite,
1799        )
1800        .await
1801        .boxed()
1802        .execute();
1803        materialize_executor.next().await.transpose().unwrap();
1804
1805        materialize_executor.next().await.transpose().unwrap();
1806
1807        // First stream chunk. We check the existence of (3) -> (3,6)
1808        match materialize_executor.next().await.transpose().unwrap() {
1809            Some(Message::Barrier(_)) => {
1810                // can read (8, 3), check insert after update
1811                let row = table
1812                    .get_row(
1813                        &OwnedRow::new(vec![Some(8_i32.into())]),
1814                        HummockReadEpoch::NoWait(u64::MAX),
1815                    )
1816                    .await
1817                    .unwrap();
1818                assert_eq!(
1819                    row,
1820                    Some(OwnedRow::new(vec![Some(8_i32.into()), Some(3_i32.into())]))
1821                );
1822            }
1823            _ => unreachable!(),
1824        }
1825        materialize_executor.next().await.transpose().unwrap();
1826
1827        match materialize_executor.next().await.transpose().unwrap() {
1828            Some(Message::Barrier(_)) => {
1829                let row = table
1830                    .get_row(
1831                        &OwnedRow::new(vec![Some(7_i32.into())]),
1832                        HummockReadEpoch::NoWait(u64::MAX),
1833                    )
1834                    .await
1835                    .unwrap();
1836                assert_eq!(
1837                    row,
1838                    Some(OwnedRow::new(vec![Some(7_i32.into()), Some(8_i32.into())]))
1839                );
1840
1841                // check delete wrong value
1842                let row = table
1843                    .get_row(
1844                        &OwnedRow::new(vec![Some(3_i32.into())]),
1845                        HummockReadEpoch::NoWait(u64::MAX),
1846                    )
1847                    .await
1848                    .unwrap();
1849                assert_eq!(row, None);
1850
1851                // check delete wrong pk
1852                let row = table
1853                    .get_row(
1854                        &OwnedRow::new(vec![Some(5_i32.into())]),
1855                        HummockReadEpoch::NoWait(u64::MAX),
1856                    )
1857                    .await
1858                    .unwrap();
1859                assert_eq!(row, None);
1860            }
1861            _ => unreachable!(),
1862        }
1863
1864        materialize_executor.next().await.transpose().unwrap();
1865        // Second stream chunk. We check the existence of (7) -> (7,8)
1866        match materialize_executor.next().await.transpose().unwrap() {
1867            Some(Message::Barrier(_)) => {
1868                let row = table
1869                    .get_row(
1870                        &OwnedRow::new(vec![Some(1_i32.into())]),
1871                        HummockReadEpoch::NoWait(u64::MAX),
1872                    )
1873                    .await
1874                    .unwrap();
1875                assert_eq!(
1876                    row,
1877                    Some(OwnedRow::new(vec![Some(1_i32.into()), Some(5_i32.into())]))
1878                );
1879
1880                // check update wrong value
1881                let row = table
1882                    .get_row(
1883                        &OwnedRow::new(vec![Some(2_i32.into())]),
1884                        HummockReadEpoch::NoWait(u64::MAX),
1885                    )
1886                    .await
1887                    .unwrap();
1888                assert_eq!(
1889                    row,
1890                    Some(OwnedRow::new(vec![Some(2_i32.into()), Some(8_i32.into())]))
1891                );
1892
1893                // check update wrong pk, should become insert
1894                let row = table
1895                    .get_row(
1896                        &OwnedRow::new(vec![Some(9_i32.into())]),
1897                        HummockReadEpoch::NoWait(u64::MAX),
1898                    )
1899                    .await
1900                    .unwrap();
1901                assert_eq!(
1902                    row,
1903                    Some(OwnedRow::new(vec![Some(9_i32.into()), Some(1_i32.into())]))
1904                );
1905            }
1906            _ => unreachable!(),
1907        }
1908    }
1909
1910    #[tokio::test]
1911    async fn test_change_buffer_into_chunk_with_stream_key() {
1912        // Table PK is (col0), but stream key is (col0, col1). When the value column changes due
1913        // to overwrite conflict handling, we should output `- old` + `+ new` rather than `U-`/`U+`.
1914        let memory_state_store = MemoryStateStore::new();
1915        let table_id = TableId::new(1);
1916        let schema = Schema::new(vec![
1917            Field::unnamed(DataType::Int32),
1918            Field::unnamed(DataType::Int32),
1919        ]);
1920        let column_ids = vec![0.into(), 1.into()];
1921
1922        let chunk1 = StreamChunk::from_pretty(
1923            " i i
1924            + 1 4",
1925        );
1926        let chunk2 = StreamChunk::from_pretty(
1927            " i i
1928            + 1 5",
1929        );
1930
1931        let source = MockSource::with_messages(vec![
1932            Message::Barrier(Barrier::new_test_barrier(test_epoch(1))),
1933            Message::Chunk(chunk1),
1934            Message::Barrier(Barrier::new_test_barrier(test_epoch(2))),
1935            Message::Chunk(chunk2),
1936            Message::Barrier(Barrier::new_test_barrier(test_epoch(3))),
1937        ])
1938        .into_executor(schema.clone(), StreamKey::new());
1939
1940        let mut materialize_executor = MaterializeExecutor::for_test_with_stream_key(
1941            source,
1942            memory_state_store,
1943            table_id,
1944            vec![ColumnOrder::new(0, OrderType::ascending())],
1945            vec![0, 1],
1946            column_ids,
1947            Arc::new(AtomicU64::new(0)),
1948            ConflictBehavior::Overwrite,
1949        )
1950        .await
1951        .boxed()
1952        .execute();
1953
1954        // init barrier + first insert
1955        materialize_executor.next().await.transpose().unwrap();
1956        materialize_executor.next().await.transpose().unwrap();
1957
1958        // commit barrier
1959        materialize_executor.next().await.transpose().unwrap();
1960
1961        // overwrite conflict should be converted into delete+insert due to stream key mismatch
1962        match materialize_executor.next().await.transpose().unwrap() {
1963            Some(Message::Chunk(chunk)) => {
1964                assert_eq!(
1965                    chunk.compact_vis(),
1966                    StreamChunk::from_pretty(
1967                        " i i
1968                        - 1 4
1969                        + 1 5"
1970                    )
1971                );
1972            }
1973            other => panic!("expect chunk, got {other:?}"),
1974        }
1975    }
1976
1977    #[tokio::test]
1978    async fn test_ignore_insert_conflict() {
1979        // Prepare storage and memtable.
1980        let memory_state_store = MemoryStateStore::new();
1981        let table_id = TableId::new(1);
1982        // Two columns of int32 type, the first column is PK.
1983        let schema = Schema::new(vec![
1984            Field::unnamed(DataType::Int32),
1985            Field::unnamed(DataType::Int32),
1986        ]);
1987        let column_ids = vec![0.into(), 1.into()];
1988
1989        // test double insert one pk, the latter needs to be ignored.
1990        let chunk1 = StreamChunk::from_pretty(
1991            " i i
1992            + 1 3
1993            + 1 4
1994            + 2 5
1995            + 3 6",
1996        );
1997
1998        let chunk2 = StreamChunk::from_pretty(
1999            " i i
2000            + 1 5
2001            + 2 6",
2002        );
2003
2004        // test delete wrong value, delete inexistent pk
2005        let chunk3 = StreamChunk::from_pretty(
2006            " i i
2007            + 1 6",
2008        );
2009
2010        // Prepare stream executors.
2011        let source = MockSource::with_messages(vec![
2012            Message::Barrier(Barrier::new_test_barrier(test_epoch(1))),
2013            Message::Chunk(chunk1),
2014            Message::Chunk(chunk2),
2015            Message::Barrier(Barrier::new_test_barrier(test_epoch(2))),
2016            Message::Chunk(chunk3),
2017            Message::Barrier(Barrier::new_test_barrier(test_epoch(3))),
2018        ])
2019        .into_executor(schema.clone(), StreamKey::new());
2020
2021        let order_types = vec![OrderType::ascending()];
2022        let column_descs = vec![
2023            ColumnDesc::unnamed(column_ids[0], DataType::Int32),
2024            ColumnDesc::unnamed(column_ids[1], DataType::Int32),
2025        ];
2026
2027        let table = BatchTable::for_test(
2028            memory_state_store.clone(),
2029            table_id,
2030            column_descs,
2031            order_types,
2032            vec![0],
2033            vec![0, 1],
2034        );
2035
2036        let mut materialize_executor = MaterializeExecutor::for_test(
2037            source,
2038            memory_state_store,
2039            table_id,
2040            vec![ColumnOrder::new(0, OrderType::ascending())],
2041            column_ids,
2042            Arc::new(AtomicU64::new(0)),
2043            ConflictBehavior::IgnoreConflict,
2044        )
2045        .await
2046        .boxed()
2047        .execute();
2048        materialize_executor.next().await.transpose().unwrap();
2049
2050        materialize_executor.next().await.transpose().unwrap();
2051        materialize_executor.next().await.transpose().unwrap();
2052
2053        // First stream chunk. We check the existence of (3) -> (3,6)
2054        match materialize_executor.next().await.transpose().unwrap() {
2055            Some(Message::Barrier(_)) => {
2056                let row = table
2057                    .get_row(
2058                        &OwnedRow::new(vec![Some(3_i32.into())]),
2059                        HummockReadEpoch::NoWait(u64::MAX),
2060                    )
2061                    .await
2062                    .unwrap();
2063                assert_eq!(
2064                    row,
2065                    Some(OwnedRow::new(vec![Some(3_i32.into()), Some(6_i32.into())]))
2066                );
2067
2068                let row = table
2069                    .get_row(
2070                        &OwnedRow::new(vec![Some(1_i32.into())]),
2071                        HummockReadEpoch::NoWait(u64::MAX),
2072                    )
2073                    .await
2074                    .unwrap();
2075                assert_eq!(
2076                    row,
2077                    Some(OwnedRow::new(vec![Some(1_i32.into()), Some(3_i32.into())]))
2078                );
2079
2080                let row = table
2081                    .get_row(
2082                        &OwnedRow::new(vec![Some(2_i32.into())]),
2083                        HummockReadEpoch::NoWait(u64::MAX),
2084                    )
2085                    .await
2086                    .unwrap();
2087                assert_eq!(
2088                    row,
2089                    Some(OwnedRow::new(vec![Some(2_i32.into()), Some(5_i32.into())]))
2090                );
2091            }
2092            _ => unreachable!(),
2093        }
2094    }
2095
2096    #[tokio::test]
2097    async fn test_ignore_delete_then_insert() {
2098        // Prepare storage and memtable.
2099        let memory_state_store = MemoryStateStore::new();
2100        let table_id = TableId::new(1);
2101        // Two columns of int32 type, the first column is PK.
2102        let schema = Schema::new(vec![
2103            Field::unnamed(DataType::Int32),
2104            Field::unnamed(DataType::Int32),
2105        ]);
2106        let column_ids = vec![0.into(), 1.into()];
2107
2108        // test insert after delete one pk, the latter insert should succeed.
2109        let chunk1 = StreamChunk::from_pretty(
2110            " i i
2111            + 1 3
2112            - 1 3
2113            + 1 6",
2114        );
2115
2116        // Prepare stream executors.
2117        let source = MockSource::with_messages(vec![
2118            Message::Barrier(Barrier::new_test_barrier(test_epoch(1))),
2119            Message::Chunk(chunk1),
2120            Message::Barrier(Barrier::new_test_barrier(test_epoch(2))),
2121        ])
2122        .into_executor(schema.clone(), StreamKey::new());
2123
2124        let order_types = vec![OrderType::ascending()];
2125        let column_descs = vec![
2126            ColumnDesc::unnamed(column_ids[0], DataType::Int32),
2127            ColumnDesc::unnamed(column_ids[1], DataType::Int32),
2128        ];
2129
2130        let table = BatchTable::for_test(
2131            memory_state_store.clone(),
2132            table_id,
2133            column_descs,
2134            order_types,
2135            vec![0],
2136            vec![0, 1],
2137        );
2138
2139        let mut materialize_executor = MaterializeExecutor::for_test(
2140            source,
2141            memory_state_store,
2142            table_id,
2143            vec![ColumnOrder::new(0, OrderType::ascending())],
2144            column_ids,
2145            Arc::new(AtomicU64::new(0)),
2146            ConflictBehavior::IgnoreConflict,
2147        )
2148        .await
2149        .boxed()
2150        .execute();
2151        let _msg1 = materialize_executor
2152            .next()
2153            .await
2154            .transpose()
2155            .unwrap()
2156            .unwrap()
2157            .as_barrier()
2158            .unwrap();
2159        let _msg2 = materialize_executor
2160            .next()
2161            .await
2162            .transpose()
2163            .unwrap()
2164            .unwrap()
2165            .as_chunk()
2166            .unwrap();
2167        let _msg3 = materialize_executor
2168            .next()
2169            .await
2170            .transpose()
2171            .unwrap()
2172            .unwrap()
2173            .as_barrier()
2174            .unwrap();
2175
2176        let row = table
2177            .get_row(
2178                &OwnedRow::new(vec![Some(1_i32.into())]),
2179                HummockReadEpoch::NoWait(u64::MAX),
2180            )
2181            .await
2182            .unwrap();
2183        assert_eq!(
2184            row,
2185            Some(OwnedRow::new(vec![Some(1_i32.into()), Some(6_i32.into())]))
2186        );
2187    }
2188
2189    #[tokio::test]
2190    async fn test_ignore_delete_and_update_conflict() {
2191        // Prepare storage and memtable.
2192        let memory_state_store = MemoryStateStore::new();
2193        let table_id = TableId::new(1);
2194        // Two columns of int32 type, the first column is PK.
2195        let schema = Schema::new(vec![
2196            Field::unnamed(DataType::Int32),
2197            Field::unnamed(DataType::Int32),
2198        ]);
2199        let column_ids = vec![0.into(), 1.into()];
2200
2201        // test double insert one pk, the latter should be ignored.
2202        let chunk1 = StreamChunk::from_pretty(
2203            " i i
2204            + 1 4
2205            + 2 5
2206            + 3 6
2207            U- 8 1
2208            U+ 8 2
2209            + 8 3",
2210        );
2211
2212        // test delete wrong value, delete inexistent pk
2213        let chunk2 = StreamChunk::from_pretty(
2214            " i i
2215            + 7 8
2216            - 3 4
2217            - 5 0",
2218        );
2219
2220        // test delete wrong value, delete inexistent pk
2221        let chunk3 = StreamChunk::from_pretty(
2222            " i i
2223            + 1 5
2224            U- 2 4
2225            U+ 2 8
2226            U- 9 0
2227            U+ 9 1",
2228        );
2229
2230        // Prepare stream executors.
2231        let source = MockSource::with_messages(vec![
2232            Message::Barrier(Barrier::new_test_barrier(test_epoch(1))),
2233            Message::Chunk(chunk1),
2234            Message::Barrier(Barrier::new_test_barrier(test_epoch(2))),
2235            Message::Chunk(chunk2),
2236            Message::Barrier(Barrier::new_test_barrier(test_epoch(3))),
2237            Message::Chunk(chunk3),
2238            Message::Barrier(Barrier::new_test_barrier(test_epoch(4))),
2239        ])
2240        .into_executor(schema.clone(), StreamKey::new());
2241
2242        let order_types = vec![OrderType::ascending()];
2243        let column_descs = vec![
2244            ColumnDesc::unnamed(column_ids[0], DataType::Int32),
2245            ColumnDesc::unnamed(column_ids[1], DataType::Int32),
2246        ];
2247
2248        let table = BatchTable::for_test(
2249            memory_state_store.clone(),
2250            table_id,
2251            column_descs,
2252            order_types,
2253            vec![0],
2254            vec![0, 1],
2255        );
2256
2257        let mut materialize_executor = MaterializeExecutor::for_test(
2258            source,
2259            memory_state_store,
2260            table_id,
2261            vec![ColumnOrder::new(0, OrderType::ascending())],
2262            column_ids,
2263            Arc::new(AtomicU64::new(0)),
2264            ConflictBehavior::IgnoreConflict,
2265        )
2266        .await
2267        .boxed()
2268        .execute();
2269        materialize_executor.next().await.transpose().unwrap();
2270
2271        materialize_executor.next().await.transpose().unwrap();
2272
2273        // First stream chunk. We check the existence of (3) -> (3,6)
2274        match materialize_executor.next().await.transpose().unwrap() {
2275            Some(Message::Barrier(_)) => {
2276                // can read (8, 2), check insert after update
2277                let row = table
2278                    .get_row(
2279                        &OwnedRow::new(vec![Some(8_i32.into())]),
2280                        HummockReadEpoch::NoWait(u64::MAX),
2281                    )
2282                    .await
2283                    .unwrap();
2284                assert_eq!(
2285                    row,
2286                    Some(OwnedRow::new(vec![Some(8_i32.into()), Some(2_i32.into())]))
2287                );
2288            }
2289            _ => unreachable!(),
2290        }
2291        materialize_executor.next().await.transpose().unwrap();
2292
2293        match materialize_executor.next().await.transpose().unwrap() {
2294            Some(Message::Barrier(_)) => {
2295                let row = table
2296                    .get_row(
2297                        &OwnedRow::new(vec![Some(7_i32.into())]),
2298                        HummockReadEpoch::NoWait(u64::MAX),
2299                    )
2300                    .await
2301                    .unwrap();
2302                assert_eq!(
2303                    row,
2304                    Some(OwnedRow::new(vec![Some(7_i32.into()), Some(8_i32.into())]))
2305                );
2306
2307                // check delete wrong value
2308                let row = table
2309                    .get_row(
2310                        &OwnedRow::new(vec![Some(3_i32.into())]),
2311                        HummockReadEpoch::NoWait(u64::MAX),
2312                    )
2313                    .await
2314                    .unwrap();
2315                assert_eq!(row, None);
2316
2317                // check delete wrong pk
2318                let row = table
2319                    .get_row(
2320                        &OwnedRow::new(vec![Some(5_i32.into())]),
2321                        HummockReadEpoch::NoWait(u64::MAX),
2322                    )
2323                    .await
2324                    .unwrap();
2325                assert_eq!(row, None);
2326            }
2327            _ => unreachable!(),
2328        }
2329
2330        materialize_executor.next().await.transpose().unwrap();
2331        // materialize_executor.next().await.transpose().unwrap();
2332        // Second stream chunk. We check the existence of (7) -> (7,8)
2333        match materialize_executor.next().await.transpose().unwrap() {
2334            Some(Message::Barrier(_)) => {
2335                let row = table
2336                    .get_row(
2337                        &OwnedRow::new(vec![Some(1_i32.into())]),
2338                        HummockReadEpoch::NoWait(u64::MAX),
2339                    )
2340                    .await
2341                    .unwrap();
2342                assert_eq!(
2343                    row,
2344                    Some(OwnedRow::new(vec![Some(1_i32.into()), Some(4_i32.into())]))
2345                );
2346
2347                // check update wrong value
2348                let row = table
2349                    .get_row(
2350                        &OwnedRow::new(vec![Some(2_i32.into())]),
2351                        HummockReadEpoch::NoWait(u64::MAX),
2352                    )
2353                    .await
2354                    .unwrap();
2355                assert_eq!(
2356                    row,
2357                    Some(OwnedRow::new(vec![Some(2_i32.into()), Some(8_i32.into())]))
2358                );
2359
2360                // check update wrong pk, should become insert
2361                let row = table
2362                    .get_row(
2363                        &OwnedRow::new(vec![Some(9_i32.into())]),
2364                        HummockReadEpoch::NoWait(u64::MAX),
2365                    )
2366                    .await
2367                    .unwrap();
2368                assert_eq!(
2369                    row,
2370                    Some(OwnedRow::new(vec![Some(9_i32.into()), Some(1_i32.into())]))
2371                );
2372            }
2373            _ => unreachable!(),
2374        }
2375    }
2376
2377    #[tokio::test]
2378    async fn test_do_update_if_not_null_conflict() {
2379        // Prepare storage and memtable.
2380        let memory_state_store = MemoryStateStore::new();
2381        let table_id = TableId::new(1);
2382        // Two columns of int32 type, the first column is PK.
2383        let schema = Schema::new(vec![
2384            Field::unnamed(DataType::Int32),
2385            Field::unnamed(DataType::Int32),
2386        ]);
2387        let column_ids = vec![0.into(), 1.into()];
2388
2389        // should get (8, 2)
2390        let chunk1 = StreamChunk::from_pretty(
2391            " i i
2392            + 1 4
2393            + 2 .
2394            + 3 6
2395            U- 8 .
2396            U+ 8 2
2397            + 8 .",
2398        );
2399
2400        // should not get (3, x), should not get (5, 0)
2401        let chunk2 = StreamChunk::from_pretty(
2402            " i i
2403            + 7 8
2404            - 3 4
2405            - 5 0",
2406        );
2407
2408        // should get (2, None), (7, 8)
2409        let chunk3 = StreamChunk::from_pretty(
2410            " i i
2411            + 1 5
2412            + 7 .
2413            U- 2 4
2414            U+ 2 .
2415            U- 9 0
2416            U+ 9 1",
2417        );
2418
2419        // Prepare stream executors.
2420        let source = MockSource::with_messages(vec![
2421            Message::Barrier(Barrier::new_test_barrier(test_epoch(1))),
2422            Message::Chunk(chunk1),
2423            Message::Barrier(Barrier::new_test_barrier(test_epoch(2))),
2424            Message::Chunk(chunk2),
2425            Message::Barrier(Barrier::new_test_barrier(test_epoch(3))),
2426            Message::Chunk(chunk3),
2427            Message::Barrier(Barrier::new_test_barrier(test_epoch(4))),
2428        ])
2429        .into_executor(schema.clone(), StreamKey::new());
2430
2431        let order_types = vec![OrderType::ascending()];
2432        let column_descs = vec![
2433            ColumnDesc::unnamed(column_ids[0], DataType::Int32),
2434            ColumnDesc::unnamed(column_ids[1], DataType::Int32),
2435        ];
2436
2437        let table = BatchTable::for_test(
2438            memory_state_store.clone(),
2439            table_id,
2440            column_descs,
2441            order_types,
2442            vec![0],
2443            vec![0, 1],
2444        );
2445
2446        let mut materialize_executor = MaterializeExecutor::for_test(
2447            source,
2448            memory_state_store,
2449            table_id,
2450            vec![ColumnOrder::new(0, OrderType::ascending())],
2451            column_ids,
2452            Arc::new(AtomicU64::new(0)),
2453            ConflictBehavior::DoUpdateIfNotNull,
2454        )
2455        .await
2456        .boxed()
2457        .execute();
2458        materialize_executor.next().await.transpose().unwrap();
2459
2460        materialize_executor.next().await.transpose().unwrap();
2461
2462        // First stream chunk. We check the existence of (3) -> (3,6)
2463        match materialize_executor.next().await.transpose().unwrap() {
2464            Some(Message::Barrier(_)) => {
2465                let row = table
2466                    .get_row(
2467                        &OwnedRow::new(vec![Some(8_i32.into())]),
2468                        HummockReadEpoch::NoWait(u64::MAX),
2469                    )
2470                    .await
2471                    .unwrap();
2472                assert_eq!(
2473                    row,
2474                    Some(OwnedRow::new(vec![Some(8_i32.into()), Some(2_i32.into())]))
2475                );
2476
2477                let row = table
2478                    .get_row(
2479                        &OwnedRow::new(vec![Some(2_i32.into())]),
2480                        HummockReadEpoch::NoWait(u64::MAX),
2481                    )
2482                    .await
2483                    .unwrap();
2484                assert_eq!(row, Some(OwnedRow::new(vec![Some(2_i32.into()), None])));
2485            }
2486            _ => unreachable!(),
2487        }
2488        materialize_executor.next().await.transpose().unwrap();
2489
2490        match materialize_executor.next().await.transpose().unwrap() {
2491            Some(Message::Barrier(_)) => {
2492                let row = table
2493                    .get_row(
2494                        &OwnedRow::new(vec![Some(7_i32.into())]),
2495                        HummockReadEpoch::NoWait(u64::MAX),
2496                    )
2497                    .await
2498                    .unwrap();
2499                assert_eq!(
2500                    row,
2501                    Some(OwnedRow::new(vec![Some(7_i32.into()), Some(8_i32.into())]))
2502                );
2503
2504                // check delete wrong value
2505                let row = table
2506                    .get_row(
2507                        &OwnedRow::new(vec![Some(3_i32.into())]),
2508                        HummockReadEpoch::NoWait(u64::MAX),
2509                    )
2510                    .await
2511                    .unwrap();
2512                assert_eq!(row, None);
2513
2514                // check delete wrong pk
2515                let row = table
2516                    .get_row(
2517                        &OwnedRow::new(vec![Some(5_i32.into())]),
2518                        HummockReadEpoch::NoWait(u64::MAX),
2519                    )
2520                    .await
2521                    .unwrap();
2522                assert_eq!(row, None);
2523            }
2524            _ => unreachable!(),
2525        }
2526
2527        materialize_executor.next().await.transpose().unwrap();
2528        // materialize_executor.next().await.transpose().unwrap();
2529        // Second stream chunk. We check the existence of (7) -> (7,8)
2530        match materialize_executor.next().await.transpose().unwrap() {
2531            Some(Message::Barrier(_)) => {
2532                let row = table
2533                    .get_row(
2534                        &OwnedRow::new(vec![Some(7_i32.into())]),
2535                        HummockReadEpoch::NoWait(u64::MAX),
2536                    )
2537                    .await
2538                    .unwrap();
2539                assert_eq!(
2540                    row,
2541                    Some(OwnedRow::new(vec![Some(7_i32.into()), Some(8_i32.into())]))
2542                );
2543
2544                // check update wrong value
2545                let row = table
2546                    .get_row(
2547                        &OwnedRow::new(vec![Some(2_i32.into())]),
2548                        HummockReadEpoch::NoWait(u64::MAX),
2549                    )
2550                    .await
2551                    .unwrap();
2552                assert_eq!(row, Some(OwnedRow::new(vec![Some(2_i32.into()), None])));
2553
2554                // check update wrong pk, should become insert
2555                let row = table
2556                    .get_row(
2557                        &OwnedRow::new(vec![Some(9_i32.into())]),
2558                        HummockReadEpoch::NoWait(u64::MAX),
2559                    )
2560                    .await
2561                    .unwrap();
2562                assert_eq!(
2563                    row,
2564                    Some(OwnedRow::new(vec![Some(9_i32.into()), Some(1_i32.into())]))
2565                );
2566            }
2567            _ => unreachable!(),
2568        }
2569    }
2570
2571    fn gen_fuzz_data(row_number: usize, chunk_size: usize) -> Vec<StreamChunk> {
2572        const KN: u32 = 4;
2573        const SEED: u64 = 998244353;
2574        let mut ret = vec![];
2575        let mut builder =
2576            StreamChunkBuilder::new(chunk_size, vec![DataType::Int32, DataType::Int32]);
2577        let mut rng = SmallRng::seed_from_u64(SEED);
2578
2579        let random_vis = |c: StreamChunk, rng: &mut SmallRng| -> StreamChunk {
2580            let len = c.data_chunk().capacity();
2581            let mut c = StreamChunkMut::from(c);
2582            for i in 0..len {
2583                c.set_vis(i, rng.random_bool(0.5));
2584            }
2585            c.into()
2586        };
2587        for _ in 0..row_number {
2588            let k = (rng.next_u32() % KN) as i32;
2589            let v = rng.next_u32() as i32;
2590            let op = if rng.random_bool(0.5) {
2591                Op::Insert
2592            } else {
2593                Op::Delete
2594            };
2595            if let Some(c) =
2596                builder.append_row(op, OwnedRow::new(vec![Some(k.into()), Some(v.into())]))
2597            {
2598                ret.push(random_vis(c, &mut rng));
2599            }
2600        }
2601        if let Some(c) = builder.take() {
2602            ret.push(random_vis(c, &mut rng));
2603        }
2604        ret
2605    }
2606
2607    async fn fuzz_test_stream_consistent_inner(conflict_behavior: ConflictBehavior) {
2608        const N: usize = 100000;
2609
2610        // Prepare storage and memtable.
2611        let memory_state_store = MemoryStateStore::new();
2612        let table_id = TableId::new(1);
2613        // Two columns of int32 type, the first column is PK.
2614        let schema = Schema::new(vec![
2615            Field::unnamed(DataType::Int32),
2616            Field::unnamed(DataType::Int32),
2617        ]);
2618        let column_ids = vec![0.into(), 1.into()];
2619
2620        let chunks = gen_fuzz_data(N, 128);
2621        let messages = iter::once(Message::Barrier(Barrier::new_test_barrier(test_epoch(1))))
2622            .chain(chunks.into_iter().map(Message::Chunk))
2623            .chain(iter::once(Message::Barrier(Barrier::new_test_barrier(
2624                test_epoch(2),
2625            ))))
2626            .collect();
2627        // Prepare stream executors.
2628        let source =
2629            MockSource::with_messages(messages).into_executor(schema.clone(), StreamKey::new());
2630
2631        let mut materialize_executor = MaterializeExecutor::for_test(
2632            source,
2633            memory_state_store.clone(),
2634            table_id,
2635            vec![ColumnOrder::new(0, OrderType::ascending())],
2636            column_ids,
2637            Arc::new(AtomicU64::new(0)),
2638            conflict_behavior,
2639        )
2640        .await
2641        .boxed()
2642        .execute();
2643        materialize_executor.expect_barrier().await;
2644
2645        let order_types = vec![OrderType::ascending()];
2646        let column_descs = vec![
2647            ColumnDesc::unnamed(0.into(), DataType::Int32),
2648            ColumnDesc::unnamed(1.into(), DataType::Int32),
2649        ];
2650        let pk_indices = vec![0];
2651
2652        let mut table = StateTable::from_table_catalog(
2653            &crate::common::table::test_utils::gen_pbtable(
2654                TableId::from(1002),
2655                column_descs.clone(),
2656                order_types,
2657                pk_indices,
2658                0,
2659            ),
2660            memory_state_store.clone(),
2661            None,
2662        )
2663        .await;
2664
2665        while let Message::Chunk(c) = materialize_executor.next().await.unwrap().unwrap() {
2666            // check with state table's memtable
2667            table.write_chunk(c);
2668        }
2669    }
2670
2671    #[tokio::test]
2672    async fn fuzz_test_stream_consistent_upsert() {
2673        fuzz_test_stream_consistent_inner(ConflictBehavior::Overwrite).await
2674    }
2675
2676    #[tokio::test]
2677    async fn fuzz_test_stream_consistent_ignore() {
2678        fuzz_test_stream_consistent_inner(ConflictBehavior::IgnoreConflict).await
2679    }
2680
2681    /// A TTL-ed table `(k, ts, v)` with pk `k`, cleaned by `ts`, has `(25, 10, 100)` and gets
2682    /// `Watermark(ts = 20)`, then `chunk` in the same epoch or after a barrier. Returns the rows
2683    /// output for `chunk`.
2684    async fn ttl_output_after_watermark(
2685        conflict_behavior: ConflictBehavior,
2686        chunk: &str,
2687        barrier_between: bool,
2688        cold_cache: bool,
2689    ) -> Vec<(Op, OwnedRow)> {
2690        let schema = Schema::new(vec![
2691            Field::unnamed(DataType::Int32),
2692            Field::unnamed(DataType::Int32),
2693            Field::unnamed(DataType::Int32),
2694        ]);
2695        let mut messages = vec![
2696            Message::Barrier(Barrier::new_test_barrier(test_epoch(1))),
2697            Message::Chunk(StreamChunk::from_pretty(
2698                " i i i
2699                + 25 10 100",
2700            )),
2701            Message::Barrier(Barrier::new_test_barrier(test_epoch(2))),
2702            Message::Watermark(Watermark::new(1, DataType::Int32, ScalarImpl::Int32(20))),
2703        ];
2704        let mut last_epoch = 3;
2705        if barrier_between {
2706            messages.push(Message::Barrier(Barrier::new_test_barrier(test_epoch(3))));
2707            last_epoch = 4;
2708        }
2709        messages.push(Message::Chunk(StreamChunk::from_pretty(chunk)));
2710        messages.push(Message::Barrier(Barrier::new_test_barrier(test_epoch(
2711            last_epoch,
2712        ))));
2713        let source = MockSource::with_messages(messages).into_executor(schema, vec![0, 1]);
2714
2715        // The maximum sequence evicts the whole cache before every message.
2716        let cache_sequence = if cold_cache { u64::MAX } else { 0 };
2717        let mut materialize = MaterializeExecutor::for_test_with_ttl(
2718            source,
2719            MemoryStateStore::new(),
2720            TableId::new(1),
2721            vec![ColumnOrder::new(0, OrderType::ascending())],
2722            vec![0.into(), 1.into(), 2.into()],
2723            Arc::new(AtomicU64::new(cache_sequence)),
2724            conflict_behavior,
2725            1,
2726        )
2727        .await
2728        .boxed()
2729        .execute();
2730
2731        let mut watermark_seen = false;
2732        let mut rows = vec![];
2733        while let Some(msg) = materialize.next().await.transpose().unwrap() {
2734            match msg {
2735                Message::Watermark(_) => watermark_seen = true,
2736                Message::Chunk(chunk) if watermark_seen => {
2737                    rows.extend(chunk.rows().map(|(op, row)| (op, row.into_owned_row())))
2738                }
2739                Message::Barrier(barrier) if barrier.epoch.curr == test_epoch(last_epoch) => break,
2740                _ => {}
2741            }
2742        }
2743        assert!(watermark_seen);
2744        rows
2745    }
2746
2747    /// The rows below the forwarded watermark are expired, even before the commit.
2748    #[tokio::test]
2749    async fn test_ttl_expired_by_forwarded_watermark() {
2750        let new_row = OwnedRow::new(vec![
2751            Some(25i32.into()),
2752            Some(30i32.into()),
2753            Some(300i32.into()),
2754        ]);
2755        let cases = [
2756            // An upsert of the expired key is a new row.
2757            (
2758                ConflictBehavior::Overwrite,
2759                " i i i\n + 25 30 300",
2760                vec![(Op::Insert, new_row.clone())],
2761            ),
2762            // A delete of the expired key is ignored.
2763            (ConflictBehavior::Overwrite, " i i i\n - 25 . .", vec![]),
2764            // The expired row does not conflict with the new one.
2765            (
2766                ConflictBehavior::IgnoreConflict,
2767                " i i i\n + 25 30 300",
2768                vec![(Op::Insert, new_row)],
2769            ),
2770        ];
2771        for (conflict_behavior, chunk, expected) in cases {
2772            for barrier_between in [false, true] {
2773                for cold_cache in [false, true] {
2774                    assert_eq!(
2775                        ttl_output_after_watermark(
2776                            conflict_behavior,
2777                            chunk,
2778                            barrier_between,
2779                            cold_cache
2780                        )
2781                        .await,
2782                        expected,
2783                        "{conflict_behavior:?}, {chunk:?}, barrier_between: {barrier_between}, \
2784                         cold_cache: {cold_cache}"
2785                    );
2786                }
2787            }
2788        }
2789    }
2790}