Skip to main content

risingwave_stream/executor/
asof_join.rs

1// Copyright 2024 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
15//! `AsOf` join executor with optional LRU cache.
16//!
17//! When `use_cache` is false, the executor directly queries the state table.
18//! When `use_cache` is true, the `AsOfJoinHashMap` maintains an LRU cache.
19//! Controlled by the session variable `streaming_asof_join_use_cache`.
20
21use std::cmp::Ordering;
22use std::collections::BTreeMap;
23use std::ops::Bound;
24use std::time::Duration;
25
26use either::Either;
27use itertools::Itertools;
28use multimap::MultiMap;
29use risingwave_common::array::Op;
30use risingwave_common::metrics::LabelGuardedHistogram;
31use risingwave_common::row::RowExt;
32use risingwave_common::util::epoch::EpochPair;
33use risingwave_common::util::sort_util::cmp_rows_ascending;
34use tokio::time::Instant;
35
36use super::barrier_align::*;
37use super::join::asof_join::*;
38use super::join::builder::JoinStreamChunkBuilder;
39use super::join::row::JoinRow;
40use super::join::*;
41use super::watermark::*;
42use crate::executor::join::builder::JoinChunkBuilder;
43use crate::executor::prelude::*;
44
45pub struct JoinParams {
46    /// Indices of the join keys
47    pub join_key_indices: Vec<usize>,
48    /// Indices of the input pk after dedup
49    pub deduped_pk_indices: Vec<usize>,
50}
51
52impl JoinParams {
53    pub fn new(join_key_indices: Vec<usize>, deduped_pk_indices: Vec<usize>) -> Self {
54        Self {
55            join_key_indices,
56            deduped_pk_indices,
57        }
58    }
59}
60
61struct JoinSide<S: StateStore, E: AsOfRowEncoding> {
62    /// Store all data from a one side stream
63    ht: AsOfJoinHashMap<S, E>,
64    /// Indices of the join key columns
65    join_key_indices: Vec<usize>,
66    /// The data type of all columns without degree.
67    all_data_types: Vec<DataType>,
68    /// The mapping from input indices of a side to output columns.
69    i2o_mapping: Vec<(usize, usize)>,
70    i2o_mapping_indexed: MultiMap<usize, usize>,
71    /// The index of the inequality column.
72    inequal_key_idx: usize,
73}
74
75impl<S: StateStore, E: AsOfRowEncoding> std::fmt::Debug for JoinSide<S, E> {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("JoinSide")
78            .field("join_key_indices", &self.join_key_indices)
79            .field("col_types", &self.all_data_types)
80            .field("i2o_mapping", &self.i2o_mapping)
81            .finish()
82    }
83}
84
85impl<S: StateStore, E: AsOfRowEncoding> JoinSide<S, E> {
86    pub async fn init(&mut self, epoch: EpochPair) -> StreamExecutorResult<()> {
87        self.ht.init(epoch).await
88    }
89}
90
91/// `AsOfJoinExecutor` for streaming as-of joins.
92///
93/// This executor uses `AsOfJoinHashMap` to support both execution modes:
94/// it can either maintain an LRU cache or query the state table directly,
95/// depending on the `use_cache` configuration.
96pub struct AsOfJoinExecutor<S: StateStore, const T: AsOfJoinTypePrimitive, E: AsOfRowEncoding> {
97    ctx: ActorContextRef,
98    info: ExecutorInfo,
99
100    /// Left input executor
101    input_l: Option<Executor>,
102    /// Right input executor
103    input_r: Option<Executor>,
104    /// The data types of the formed new columns
105    actual_output_data_types: Vec<DataType>,
106    /// The parameters of the left join executor
107    side_l: JoinSide<S, E>,
108    /// The parameters of the right join executor
109    side_r: JoinSide<S, E>,
110
111    /// Whether nulls are considered equal for each join key column
112    null_safe: Vec<bool>,
113
114    metrics: Arc<StreamingMetrics>,
115    /// The maximum size of the chunk produced by executor at a time
116    chunk_size: usize,
117    /// watermark column index -> `BufferedWatermarks`
118    watermark_buffers: BTreeMap<usize, BufferedWatermarks<SideTypePrimitive>>,
119    /// `AsOf` join description
120    asof_desc: AsOfDesc,
121    /// Row counter for periodic cache eviction
122    cnt_rows_received: u32,
123    /// Number of processed rows between periodic manual evictions of the join cache.
124    join_cache_evict_interval_rows: u32,
125    /// Threshold for logging high join amplification warnings.
126    high_join_amplification_threshold: usize,
127}
128
129impl<S: StateStore, const T: AsOfJoinTypePrimitive, E: AsOfRowEncoding> std::fmt::Debug
130    for AsOfJoinExecutor<S, T, E>
131{
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.debug_struct("AsOfJoinExecutor")
134            .field("join_type", &T)
135            .field("input_left", &self.input_l.as_ref().unwrap().identity())
136            .field("input_right", &self.input_r.as_ref().unwrap().identity())
137            .field("side_l", &self.side_l)
138            .field("side_r", &self.side_r)
139            .field("stream_key", &self.info.stream_key)
140            .field("schema", &self.info.schema)
141            .field("actual_output_data_types", &self.actual_output_data_types)
142            .finish()
143    }
144}
145
146impl<S: StateStore, const T: AsOfJoinTypePrimitive, E: AsOfRowEncoding> Execute
147    for AsOfJoinExecutor<S, T, E>
148{
149    fn execute(self: Box<Self>) -> BoxedMessageStream {
150        self.into_stream().boxed()
151    }
152}
153
154struct EqJoinArgs<'a, S: StateStore, E: AsOfRowEncoding> {
155    ctx: &'a ActorContextRef,
156    side_l: &'a mut JoinSide<S, E>,
157    side_r: &'a mut JoinSide<S, E>,
158    null_safe: &'a [bool],
159    asof_desc: &'a AsOfDesc,
160    actual_output_data_types: &'a [DataType],
161    chunk: StreamChunk,
162    chunk_size: usize,
163    cnt_rows_received: &'a mut u32,
164    join_cache_evict_interval_rows: u32,
165    high_join_amplification_threshold: usize,
166    /// Bound at executor scope so the guard isn't dropped between chunks (which resets the series).
167    join_matched_join_keys: &'a LabelGuardedHistogram,
168}
169
170impl<S: StateStore, const T: AsOfJoinTypePrimitive, E: AsOfRowEncoding> AsOfJoinExecutor<S, T, E> {
171    #[expect(clippy::too_many_arguments)]
172    pub fn new(
173        ctx: ActorContextRef,
174        info: ExecutorInfo,
175        input_l: Executor,
176        input_r: Executor,
177        params_l: JoinParams,
178        params_r: JoinParams,
179        null_safe: Vec<bool>,
180        output_indices: Vec<usize>,
181        state_table_l: StateTable<S>,
182        state_table_r: StateTable<S>,
183        watermark_epoch: AtomicU64Ref,
184        metrics: Arc<StreamingMetrics>,
185        chunk_size: usize,
186        asof_desc: AsOfDesc,
187        use_cache: bool,
188        high_join_amplification_threshold: usize,
189    ) -> Self {
190        let join_cache_evict_interval_rows = ctx
191            .config
192            .developer
193            .join_hash_map_evict_interval_rows
194            .max(1);
195        let cache_epoch = if use_cache {
196            Some(watermark_epoch)
197        } else {
198            None
199        };
200        let schema_fields = [
201            input_l.schema().fields.clone(),
202            input_r.schema().fields.clone(),
203        ]
204        .concat();
205
206        let original_output_data_types = schema_fields
207            .iter()
208            .map(|field| field.data_type())
209            .collect_vec();
210        let actual_output_data_types = output_indices
211            .iter()
212            .map(|&idx| original_output_data_types[idx].clone())
213            .collect_vec();
214
215        let state_all_data_types_l = input_l.schema().data_types();
216        let state_all_data_types_r = input_r.schema().data_types();
217
218        let state_join_key_indices_l = params_l.join_key_indices;
219        let state_join_key_indices_r = params_r.join_key_indices;
220
221        let join_key_data_types_l = state_join_key_indices_l
222            .iter()
223            .map(|idx| state_all_data_types_l[*idx].clone())
224            .collect_vec();
225
226        let join_key_data_types_r = state_join_key_indices_r
227            .iter()
228            .map(|idx| state_all_data_types_r[*idx].clone())
229            .collect_vec();
230
231        assert_eq!(join_key_data_types_l, join_key_data_types_r);
232
233        let (left_to_output, right_to_output) = {
234            let (left_len, right_len) = if is_left_semi_or_anti(T) {
235                (state_all_data_types_l.len(), 0usize)
236            } else if is_right_semi_or_anti(T) {
237                (0usize, state_all_data_types_r.len())
238            } else {
239                (state_all_data_types_l.len(), state_all_data_types_r.len())
240            };
241            JoinStreamChunkBuilder::get_i2o_mapping(&output_indices, left_len, right_len)
242        };
243
244        let l2o_indexed = MultiMap::from_iter(left_to_output.iter().copied());
245        let r2o_indexed = MultiMap::from_iter(right_to_output.iter().copied());
246
247        let watermark_buffers = BTreeMap::new();
248
249        let inequal_key_idx_l = asof_desc.left_idx;
250        let inequal_key_idx_r = asof_desc.right_idx;
251
252        Self {
253            ctx: ctx.clone(),
254            info,
255            input_l: Some(input_l),
256            input_r: Some(input_r),
257            actual_output_data_types,
258            null_safe,
259            side_l: JoinSide {
260                ht: AsOfJoinHashMap::new(
261                    state_join_key_indices_l.clone(),
262                    state_table_l,
263                    params_l.deduped_pk_indices,
264                    inequal_key_idx_l,
265                    state_all_data_types_l.clone(),
266                    cache_epoch.clone(),
267                    metrics.clone(),
268                    ctx.id,
269                    ctx.fragment_id,
270                    "left",
271                ),
272                join_key_indices: state_join_key_indices_l,
273                all_data_types: state_all_data_types_l,
274                i2o_mapping: left_to_output,
275                i2o_mapping_indexed: l2o_indexed,
276                inequal_key_idx: inequal_key_idx_l,
277            },
278            side_r: JoinSide {
279                ht: AsOfJoinHashMap::new(
280                    state_join_key_indices_r.clone(),
281                    state_table_r,
282                    params_r.deduped_pk_indices,
283                    inequal_key_idx_r,
284                    state_all_data_types_r.clone(),
285                    cache_epoch,
286                    metrics.clone(),
287                    ctx.id,
288                    ctx.fragment_id,
289                    "right",
290                ),
291                join_key_indices: state_join_key_indices_r,
292                all_data_types: state_all_data_types_r,
293                i2o_mapping: right_to_output,
294                i2o_mapping_indexed: r2o_indexed,
295                inequal_key_idx: inequal_key_idx_r,
296            },
297            metrics,
298            chunk_size,
299            watermark_buffers,
300            asof_desc,
301            cnt_rows_received: 0,
302            join_cache_evict_interval_rows,
303            high_join_amplification_threshold,
304        }
305    }
306
307    /// Periodically evict the LRU cache to prevent memory buildup between barriers.
308    fn evict_cache(
309        side_l: &mut JoinSide<S, E>,
310        side_r: &mut JoinSide<S, E>,
311        cnt_rows_received: &mut u32,
312        join_cache_evict_interval_rows: u32,
313    ) {
314        *cnt_rows_received += 1;
315        if *cnt_rows_received >= join_cache_evict_interval_rows {
316            side_l.ht.evict_cache();
317            side_r.ht.evict_cache();
318            *cnt_rows_received = 0;
319        }
320    }
321
322    #[try_stream(ok = Message, error = StreamExecutorError)]
323    async fn into_stream(mut self) {
324        let input_l = self.input_l.take().unwrap();
325        let input_r = self.input_r.take().unwrap();
326        let aligned_stream = barrier_align(
327            input_l.execute(),
328            input_r.execute(),
329            self.ctx.id,
330            self.ctx.fragment_id,
331            self.metrics.clone(),
332            "Join",
333        );
334        pin_mut!(aligned_stream);
335        let actor_id = self.ctx.id;
336
337        let barrier = expect_first_barrier_from_aligned_stream(&mut aligned_stream).await?;
338        let first_epoch = barrier.epoch;
339        yield Message::Barrier(barrier);
340        self.side_l.init(first_epoch).await?;
341        self.side_r.init(first_epoch).await?;
342
343        let actor_id_str = self.ctx.id.to_string();
344        let fragment_id_str = self.ctx.fragment_id.to_string();
345
346        let join_actor_input_waiting_duration_ns = self
347            .metrics
348            .join_actor_input_waiting_duration_ns
349            .with_guarded_label_values(&[&actor_id_str, &fragment_id_str]);
350        let left_join_match_duration_ns = self
351            .metrics
352            .join_match_duration_ns
353            .with_guarded_label_values(&[actor_id_str.as_str(), fragment_id_str.as_str(), "left"]);
354        let right_join_match_duration_ns = self
355            .metrics
356            .join_match_duration_ns
357            .with_guarded_label_values(&[actor_id_str.as_str(), fragment_id_str.as_str(), "right"]);
358
359        let barrier_join_match_duration_ns = self
360            .metrics
361            .join_match_duration_ns
362            .with_guarded_label_values(&[
363                actor_id_str.as_str(),
364                fragment_id_str.as_str(),
365                "barrier",
366            ]);
367
368        let left_join_cached_entry_count = self
369            .metrics
370            .join_cached_entry_count
371            .with_guarded_label_values(&[actor_id_str.as_str(), fragment_id_str.as_str(), "left"]);
372
373        let right_join_cached_entry_count = self
374            .metrics
375            .join_cached_entry_count
376            .with_guarded_label_values(&[actor_id_str.as_str(), fragment_id_str.as_str(), "right"]);
377
378        // Bind at executor scope: a per-chunk guard would be dropped between chunks and reset the series.
379        let left_table_id_str = self.side_l.ht.table_id().to_string();
380        let right_table_id_str = self.side_r.ht.table_id().to_string();
381        let left_join_matched_join_keys = self
382            .metrics
383            .join_matched_join_keys
384            .with_guarded_label_values(&[
385                actor_id_str.as_str(),
386                fragment_id_str.as_str(),
387                left_table_id_str.as_str(),
388            ]);
389        let right_join_matched_join_keys = self
390            .metrics
391            .join_matched_join_keys
392            .with_guarded_label_values(&[
393                actor_id_str.as_str(),
394                fragment_id_str.as_str(),
395                right_table_id_str.as_str(),
396            ]);
397
398        let mut start_time = Instant::now();
399
400        while let Some(msg) = aligned_stream
401            .next()
402            .instrument_await("hash_join_barrier_align")
403            .await
404        {
405            join_actor_input_waiting_duration_ns.inc_by(start_time.elapsed().as_nanos() as u64);
406            match msg? {
407                AlignedMessage::WatermarkLeft(watermark) => {
408                    for watermark_to_emit in self.handle_watermark(SideType::Left, watermark)? {
409                        yield Message::Watermark(watermark_to_emit);
410                    }
411                }
412                AlignedMessage::WatermarkRight(watermark) => {
413                    for watermark_to_emit in self.handle_watermark(SideType::Right, watermark)? {
414                        yield Message::Watermark(watermark_to_emit);
415                    }
416                }
417                AlignedMessage::Left(chunk) => {
418                    let mut left_time = Duration::from_nanos(0);
419                    let mut left_start_time = Instant::now();
420                    #[for_await]
421                    for chunk in Self::eq_join_left(EqJoinArgs {
422                        ctx: &self.ctx,
423                        side_l: &mut self.side_l,
424                        side_r: &mut self.side_r,
425                        null_safe: &self.null_safe,
426                        asof_desc: &self.asof_desc,
427                        actual_output_data_types: &self.actual_output_data_types,
428                        chunk,
429                        chunk_size: self.chunk_size,
430                        cnt_rows_received: &mut self.cnt_rows_received,
431                        join_cache_evict_interval_rows: self.join_cache_evict_interval_rows,
432                        high_join_amplification_threshold: self.high_join_amplification_threshold,
433                        join_matched_join_keys: &left_join_matched_join_keys,
434                    }) {
435                        left_time += left_start_time.elapsed();
436                        yield Message::Chunk(chunk?);
437                        left_start_time = Instant::now();
438                    }
439                    left_time += left_start_time.elapsed();
440                    left_join_match_duration_ns.inc_by(left_time.as_nanos() as u64);
441                    self.try_flush_data().await?;
442                }
443                AlignedMessage::Right(chunk) => {
444                    let mut right_time = Duration::from_nanos(0);
445                    let mut right_start_time = Instant::now();
446                    #[for_await]
447                    for chunk in Self::eq_join_right(EqJoinArgs {
448                        ctx: &self.ctx,
449                        side_l: &mut self.side_l,
450                        side_r: &mut self.side_r,
451                        null_safe: &self.null_safe,
452                        asof_desc: &self.asof_desc,
453                        actual_output_data_types: &self.actual_output_data_types,
454                        chunk,
455                        chunk_size: self.chunk_size,
456                        cnt_rows_received: &mut self.cnt_rows_received,
457                        join_cache_evict_interval_rows: self.join_cache_evict_interval_rows,
458                        high_join_amplification_threshold: self.high_join_amplification_threshold,
459                        join_matched_join_keys: &right_join_matched_join_keys,
460                    }) {
461                        right_time += right_start_time.elapsed();
462                        yield Message::Chunk(chunk?);
463                        right_start_time = Instant::now();
464                    }
465                    right_time += right_start_time.elapsed();
466                    right_join_match_duration_ns.inc_by(right_time.as_nanos() as u64);
467                    self.try_flush_data().await?;
468                }
469                AlignedMessage::Barrier(barrier) => {
470                    let barrier_start_time = Instant::now();
471                    let (left_post_commit, right_post_commit) =
472                        self.flush_data(barrier.epoch).await?;
473
474                    let update_vnode_bitmap = barrier.as_update_vnode_bitmap(actor_id);
475                    yield Message::Barrier(barrier);
476
477                    // Update the vnode bitmap for state tables of both sides if asked.
478                    right_post_commit
479                        .post_yield_barrier(update_vnode_bitmap.clone())
480                        .await?;
481                    if left_post_commit
482                        .post_yield_barrier(update_vnode_bitmap)
483                        .await?
484                        .unwrap_or(false)
485                    {
486                        self.watermark_buffers
487                            .values_mut()
488                            .for_each(|buffers| buffers.clear());
489                    }
490
491                    // Report metrics of cached join rows/entries
492                    for (join_cached_entry_count, ht) in [
493                        (&left_join_cached_entry_count, &self.side_l.ht),
494                        (&right_join_cached_entry_count, &self.side_r.ht),
495                    ] {
496                        join_cached_entry_count.set(ht.entry_count() as i64);
497                    }
498
499                    barrier_join_match_duration_ns
500                        .inc_by(barrier_start_time.elapsed().as_nanos() as u64);
501                }
502            }
503            start_time = Instant::now();
504        }
505    }
506
507    async fn flush_data(
508        &mut self,
509        epoch: EpochPair,
510    ) -> StreamExecutorResult<(
511        AsOfJoinHashMapPostCommit<'_, S, E>,
512        AsOfJoinHashMapPostCommit<'_, S, E>,
513    )> {
514        let left = self.side_l.ht.flush(epoch).await?;
515        let right = self.side_r.ht.flush(epoch).await?;
516        Ok((left, right))
517    }
518
519    async fn try_flush_data(&mut self) -> StreamExecutorResult<()> {
520        self.side_l.ht.try_flush().await?;
521        self.side_r.ht.try_flush().await?;
522        Ok(())
523    }
524
525    fn handle_watermark(
526        &mut self,
527        side: SideTypePrimitive,
528        watermark: Watermark,
529    ) -> StreamExecutorResult<Vec<Watermark>> {
530        let (side_update, side_match) = if side == SideType::Left {
531            (&mut self.side_l, &mut self.side_r)
532        } else {
533            (&mut self.side_r, &mut self.side_l)
534        };
535
536        // Select watermarks to yield.
537        let wm_in_jk = side_update
538            .join_key_indices
539            .iter()
540            .positions(|idx| *idx == watermark.col_idx);
541        let mut watermarks_to_emit = vec![];
542        let mut clean_watermark = None;
543        for idx in wm_in_jk {
544            let buffers = self
545                .watermark_buffers
546                .entry(idx)
547                .or_insert_with(|| BufferedWatermarks::with_ids([SideType::Left, SideType::Right]));
548            if let Some(selected_watermark) = buffers.handle_watermark(side, watermark.clone()) {
549                if idx == 0 {
550                    clean_watermark = Some(selected_watermark.val.clone());
551                }
552                let empty_indices = vec![];
553                let output_indices = side_update
554                    .i2o_mapping_indexed
555                    .get_vec(&side_update.join_key_indices[idx])
556                    .unwrap_or(&empty_indices)
557                    .iter()
558                    .chain(
559                        side_match
560                            .i2o_mapping_indexed
561                            .get_vec(&side_match.join_key_indices[idx])
562                            .unwrap_or(&empty_indices),
563                    );
564                for output_idx in output_indices {
565                    watermarks_to_emit.push(selected_watermark.clone().with_idx(*output_idx));
566                }
567            };
568        }
569        // State cleaning. Until the watermarks of both sides pass a join key, later rows of the
570        // lagging side may still read and write the rows of the key in both tables.
571        if let Some(watermark) = clean_watermark {
572            self.side_l.ht.update_watermark(watermark.clone());
573            self.side_r.ht.update_watermark(watermark);
574        }
575        Ok(watermarks_to_emit)
576    }
577
578    #[try_stream(ok = StreamChunk, error = StreamExecutorError)]
579    async fn eq_join_left(args: EqJoinArgs<'_, S, E>) {
580        let EqJoinArgs {
581            ctx,
582            side_l,
583            side_r,
584            null_safe,
585            asof_desc,
586            actual_output_data_types,
587            chunk,
588            chunk_size,
589            cnt_rows_received,
590            join_cache_evict_interval_rows,
591            high_join_amplification_threshold,
592            join_matched_join_keys,
593        } = args;
594
595        let (side_update, side_match) = (side_l, side_r);
596
597        let mut join_chunk_builder =
598            JoinChunkBuilder::<T, { SideType::Left }>::new(JoinStreamChunkBuilder::new(
599                chunk_size,
600                actual_output_data_types.to_vec(),
601                side_update.i2o_mapping.clone(),
602                side_match.i2o_mapping.clone(),
603            ));
604
605        // The inequality key is always a single column; wrap in an array for `project()`.
606        let inequal_key_idx_update = [side_update.inequal_key_idx];
607
608        for r in chunk.rows_with_holes() {
609            let Some((op, row)) = r else {
610                continue;
611            };
612            Self::evict_cache(
613                side_update,
614                side_match,
615                cnt_rows_received,
616                join_cache_evict_interval_rows,
617            );
618
619            // Check null-safe: if any non-null-safe join key column is NULL, skip matching.
620            // Rows that can never match are not stored in state (consistent with old behavior).
621            let join_key_null_not_safe = side_update
622                .join_key_indices
623                .iter()
624                .zip_eq(null_safe.iter())
625                .any(|(idx, ns)| !ns && row.datum_at(*idx).is_none());
626            if join_key_null_not_safe {
627                match op {
628                    Op::Insert | Op::UpdateInsert => {
629                        if let Some(chunk) =
630                            join_chunk_builder.forward_if_not_matched(Op::Insert, row)
631                        {
632                            yield chunk;
633                        }
634                    }
635                    Op::Delete | Op::UpdateDelete => {
636                        if let Some(chunk) =
637                            join_chunk_builder.forward_if_not_matched(Op::Delete, row)
638                        {
639                            yield chunk;
640                        }
641                    }
642                }
643                join_matched_join_keys.observe(0.0);
644                continue;
645            }
646
647            let join_key = row.project(&side_update.join_key_indices);
648
649            let inequal_key_is_null = side_update.ht.check_inequal_key_null(&row);
650            let inequal_key = row.project(&inequal_key_idx_update);
651
652            let mut join_matched_rows_cnt = 0;
653
654            if !inequal_key_is_null {
655                let matched_row_by_inequality = match asof_desc.inequality_type {
656                    AsOfInequalityType::Lt => {
657                        side_match
658                            .ht
659                            .lower_bound_by_inequality_with_jk_prefix(
660                                &join_key,
661                                Bound::Excluded(&inequal_key),
662                            )
663                            .await
664                    }
665                    AsOfInequalityType::Le => {
666                        side_match
667                            .ht
668                            .lower_bound_by_inequality_with_jk_prefix(
669                                &join_key,
670                                Bound::Included(&inequal_key),
671                            )
672                            .await
673                    }
674                    AsOfInequalityType::Gt => {
675                        side_match
676                            .ht
677                            .upper_bound_by_inequality_with_jk_prefix(
678                                &join_key,
679                                Bound::Excluded(&inequal_key),
680                            )
681                            .await
682                    }
683                    AsOfInequalityType::Ge => {
684                        side_match
685                            .ht
686                            .upper_bound_by_inequality_with_jk_prefix(
687                                &join_key,
688                                Bound::Included(&inequal_key),
689                            )
690                            .await
691                    }
692                }?
693                .map(|row| JoinRow::new(row, 0));
694                match op {
695                    Op::Insert | Op::UpdateInsert => {
696                        if let Some(matched_row) = matched_row_by_inequality {
697                            join_matched_rows_cnt += 1;
698                            if let Some(chunk) =
699                                join_chunk_builder.with_match_on_insert(&row, &matched_row)
700                            {
701                                yield chunk;
702                            }
703                        } else if let Some(chunk) =
704                            join_chunk_builder.forward_if_not_matched(Op::Insert, row)
705                        {
706                            yield chunk;
707                        }
708                        side_update.ht.insert(row)?;
709                    }
710                    Op::Delete | Op::UpdateDelete => {
711                        if let Some(matched_row) = matched_row_by_inequality {
712                            join_matched_rows_cnt += 1;
713                            if let Some(chunk) =
714                                join_chunk_builder.with_match_on_delete(&row, &matched_row)
715                            {
716                                yield chunk;
717                            }
718                        } else if let Some(chunk) =
719                            join_chunk_builder.forward_if_not_matched(Op::Delete, row)
720                        {
721                            yield chunk;
722                        }
723                        side_update.ht.delete(row)?;
724                    }
725                }
726            } else {
727                // Inequality key is NULL, which can never satisfy the inequality predicate,
728                // so we skip storing and only forward as unmatched for left outer join.
729                match op {
730                    Op::Insert | Op::UpdateInsert => {
731                        if let Some(chunk) =
732                            join_chunk_builder.forward_if_not_matched(Op::Insert, row)
733                        {
734                            yield chunk;
735                        }
736                    }
737                    Op::Delete | Op::UpdateDelete => {
738                        if let Some(chunk) =
739                            join_chunk_builder.forward_if_not_matched(Op::Delete, row)
740                        {
741                            yield chunk;
742                        }
743                    }
744                }
745            }
746            join_matched_join_keys.observe(join_matched_rows_cnt as _);
747            if join_matched_rows_cnt > high_join_amplification_threshold {
748                tracing::warn!(target: "high_join_amplification",
749                    matched_rows_len = join_matched_rows_cnt,
750                    update_table_id = %side_update.ht.table_id(),
751                    match_table_id = %side_match.ht.table_id(),
752                    join_key = ?join_key,
753                    actor_id = %ctx.id,
754                    fragment_id = %ctx.fragment_id,
755                    "large rows matched for join key when AsOf join updating left side",
756                );
757            }
758        }
759        if let Some(chunk) = join_chunk_builder.take() {
760            yield chunk;
761        }
762    }
763
764    fn cmp_pk_rows(pk1: &impl Row, pk2: &impl Row) -> Ordering {
765        cmp_rows_ascending(pk1, pk2)
766    }
767
768    #[try_stream(ok = StreamChunk, error = StreamExecutorError)]
769    async fn eq_join_right(args: EqJoinArgs<'_, S, E>) {
770        let EqJoinArgs {
771            ctx,
772            side_l,
773            side_r,
774            null_safe,
775            asof_desc,
776            actual_output_data_types,
777            chunk,
778            chunk_size,
779            cnt_rows_received,
780            join_cache_evict_interval_rows,
781            high_join_amplification_threshold,
782            join_matched_join_keys,
783        } = args;
784
785        let (side_update, side_match) = (side_r, side_l);
786
787        let mut join_chunk_builder = JoinStreamChunkBuilder::new(
788            chunk_size,
789            actual_output_data_types.to_vec(),
790            side_update.i2o_mapping.clone(),
791            side_match.i2o_mapping.clone(),
792        );
793
794        // The inequality key is always a single column; wrap in an array for `project()`.
795        let inequal_key_idx_update = [side_update.inequal_key_idx];
796
797        for r in chunk.rows_with_holes() {
798            let Some((op, row)) = r else {
799                continue;
800            };
801            Self::evict_cache(
802                side_update,
803                side_match,
804                cnt_rows_received,
805                join_cache_evict_interval_rows,
806            );
807
808            // Check null-safe: if any non-null-safe join key column is NULL, skip.
809            // Rows that can never match are not stored in state (consistent with old behavior).
810            let join_key_null_not_safe = side_update
811                .join_key_indices
812                .iter()
813                .zip_eq(null_safe.iter())
814                .any(|(idx, ns)| !ns && row.datum_at(*idx).is_none());
815            if join_key_null_not_safe {
816                join_matched_join_keys.observe(0.0);
817                continue;
818            }
819
820            let join_key = row.project(&side_update.join_key_indices);
821
822            let inequal_key_is_null = side_update.ht.check_inequal_key_null(&row);
823            let inequal_key = row.project(&inequal_key_idx_update);
824
825            let mut join_matched_rows_cnt = 0;
826
827            if !inequal_key_is_null {
828                let (row_to_delete_r, row_to_insert_r) = {
829                    let (first_row, second_row) = side_update
830                        .ht
831                        .first_two_by_inequality_with_jk_prefix(&join_key, &inequal_key)
832                        .await?;
833                    if let Some(first_row) = first_row {
834                        let row_pk = side_update.ht.get_pk_from_row(row);
835                        let first_pk = side_update.ht.get_pk_from_row(&first_row).to_owned_row();
836                        match op {
837                            Op::Insert | Op::UpdateInsert => {
838                                // If there are multiple rows match the inequality key in the right table, we use one with smallest pk.
839                                if Self::cmp_pk_rows(&first_pk, &row_pk) == Ordering::Greater {
840                                    (Some(Either::Left(first_row)), Some(Either::Right(row)))
841                                } else {
842                                    // No affected row in the right table.
843                                    (None, None)
844                                }
845                            }
846                            Op::Delete | Op::UpdateDelete => {
847                                if Self::cmp_pk_rows(&first_pk, &row_pk) == Ordering::Equal {
848                                    if let Some(second_row) = second_row {
849                                        (Some(Either::Right(row)), Some(Either::Left(second_row)))
850                                    } else {
851                                        (Some(Either::Right(row)), None)
852                                    }
853                                } else {
854                                    // No affected row in the right table.
855                                    (None, None)
856                                }
857                            }
858                        }
859                    } else {
860                        match op {
861                            // Decide the row_to_delete later
862                            Op::Insert | Op::UpdateInsert => (None, Some(Either::Right(row))),
863                            // Decide the row_to_insert later
864                            Op::Delete | Op::UpdateDelete => (Some(Either::Right(row)), None),
865                        }
866                    }
867                };
868                // 4 cases for row_to_delete_r and row_to_insert_r:
869                // 1. Some(_), Some(_): delete row_to_delete_r and insert row_to_insert_r
870                // 2. None, Some(_)   : row_to_delete to be decided by the nearest inequality key
871                // 3. Some(_), None   : row_to_insert to be decided by the nearest inequality key
872                // 4. None, None      : do nothing
873                if row_to_delete_r.is_none() && row_to_insert_r.is_none() {
874                    // no row to delete or insert.
875                } else {
876                    let prev_inequality_key = side_update
877                        .ht
878                        .upper_bound_by_inequality_with_jk_prefix(
879                            &join_key,
880                            Bound::Excluded(&inequal_key),
881                        )
882                        .await?
883                        .map(|r| r.project(&inequal_key_idx_update));
884                    let next_inequality_key = side_update
885                        .ht
886                        .lower_bound_by_inequality_with_jk_prefix(
887                            &join_key,
888                            Bound::Excluded(&inequal_key),
889                        )
890                        .await?
891                        .map(|r| r.project(&inequal_key_idx_update));
892
893                    let affected_inequality_key_r = match asof_desc.inequality_type {
894                        AsOfInequalityType::Lt | AsOfInequalityType::Le => &next_inequality_key,
895                        AsOfInequalityType::Gt | AsOfInequalityType::Ge => &prev_inequality_key,
896                    };
897                    let affected_row_r =
898                        if let Some(affected_inequality_key_r) = affected_inequality_key_r {
899                            side_update
900                                .ht
901                                .first_by_inequality_with_jk_prefix(
902                                    &join_key,
903                                    &affected_inequality_key_r,
904                                )
905                                .await?
906                        } else {
907                            None
908                        }
909                        .map(Either::Left);
910
911                    let (row_to_delete_r, row_to_insert_r) =
912                        match (&row_to_delete_r, &row_to_insert_r) {
913                            (Some(_), Some(_)) => (row_to_delete_r, row_to_insert_r),
914                            (None, Some(_)) => (affected_row_r, row_to_insert_r),
915                            (Some(_), None) => (row_to_delete_r, affected_row_r),
916                            (None, None) => unreachable!(),
917                        };
918                    let range = match asof_desc.inequality_type {
919                        AsOfInequalityType::Lt => (
920                            prev_inequality_key
921                                .map(Either::Left)
922                                .map_or_else(|| Bound::Unbounded, Bound::Included),
923                            Bound::Excluded(Either::Right(&inequal_key)),
924                        ),
925                        AsOfInequalityType::Le => (
926                            prev_inequality_key
927                                .map(Either::Left)
928                                .map_or_else(|| Bound::Unbounded, Bound::Excluded),
929                            Bound::Included(Either::Right(&inequal_key)),
930                        ),
931                        AsOfInequalityType::Gt => (
932                            Bound::Excluded(Either::Right(&inequal_key)),
933                            next_inequality_key
934                                .map(Either::Left)
935                                .map_or_else(|| Bound::Unbounded, Bound::Included),
936                        ),
937                        AsOfInequalityType::Ge => (
938                            Bound::Included(Either::Right(&inequal_key)),
939                            next_inequality_key
940                                .map(Either::Left)
941                                .map_or_else(|| Bound::Unbounded, Bound::Excluded),
942                        ),
943                    };
944
945                    let rows_l_stream = side_match
946                        .ht
947                        .range_by_inequality_with_jk_prefix(&join_key, &range)
948                        .await?;
949                    #[for_await]
950                    for row_l in rows_l_stream {
951                        let row_l = row_l?;
952                        join_matched_rows_cnt += 1;
953                        if let Some(row_to_delete_r) = &row_to_delete_r {
954                            if let Some(chunk) =
955                                join_chunk_builder.append_row(Op::Delete, row_to_delete_r, &row_l)
956                            {
957                                yield chunk;
958                            }
959                        } else if is_as_of_left_outer(T)
960                            && let Some(chunk) =
961                                join_chunk_builder.append_row_matched(Op::Delete, &row_l)
962                        {
963                            yield chunk;
964                        }
965                        if let Some(row_to_insert_r) = &row_to_insert_r {
966                            if let Some(chunk) =
967                                join_chunk_builder.append_row(Op::Insert, row_to_insert_r, &row_l)
968                            {
969                                yield chunk;
970                            }
971                        } else if is_as_of_left_outer(T)
972                            && let Some(chunk) =
973                                join_chunk_builder.append_row_matched(Op::Insert, &row_l)
974                        {
975                            yield chunk;
976                        }
977                    }
978                }
979
980                match op {
981                    Op::Insert | Op::UpdateInsert => {
982                        side_update.ht.insert(row)?;
983                    }
984                    Op::Delete | Op::UpdateDelete => {
985                        side_update.ht.delete(row)?;
986                    }
987                }
988            } else {
989                // Inequality key is NULL, which can never satisfy the inequality predicate,
990                // so we skip storing. No action needed on the right side.
991            }
992            join_matched_join_keys.observe(join_matched_rows_cnt as _);
993            if join_matched_rows_cnt > high_join_amplification_threshold {
994                let join_key = row.project(&side_update.join_key_indices);
995                tracing::warn!(target: "high_join_amplification",
996                    matched_rows_len = join_matched_rows_cnt,
997                    update_table_id = %side_update.ht.table_id(),
998                    match_table_id = %side_match.ht.table_id(),
999                    join_key = ?join_key,
1000                    actor_id = %ctx.id,
1001                    fragment_id = %ctx.fragment_id,
1002                    "large rows matched for join key when AsOf join updating right side",
1003                );
1004            }
1005        }
1006        if let Some(chunk) = join_chunk_builder.take() {
1007            yield chunk;
1008        }
1009    }
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014    use std::sync::atomic::AtomicU64;
1015
1016    use futures::TryStreamExt;
1017    use prometheus::Registry;
1018    use risingwave_common::array::*;
1019    use risingwave_common::catalog::{ColumnDesc, ColumnId, Field, TableId};
1020    use risingwave_common::config::MetricLevel;
1021    use risingwave_common::metrics::get_label;
1022    use risingwave_common::row;
1023    use risingwave_common::util::epoch::test_epoch;
1024    use risingwave_common::util::sort_util::OrderType;
1025    use risingwave_storage::memory::MemoryStateStore;
1026    use risingwave_storage::store::PrefetchOptions;
1027
1028    use super::*;
1029    use crate::common::table::test_utils::gen_pbtable;
1030    use crate::executor::test_utils::{MessageSender, MockSource, StreamExecutorTestExt};
1031
1032    async fn create_in_memory_state_table(
1033        mem_state: MemoryStateStore,
1034        data_types: &[DataType],
1035        order_types: &[OrderType],
1036        pk_indices: &[usize],
1037        table_id: u32,
1038    ) -> StateTable<MemoryStateStore> {
1039        let column_descs = data_types
1040            .iter()
1041            .enumerate()
1042            .map(|(id, data_type)| ColumnDesc::unnamed(ColumnId::new(id as i32), data_type.clone()))
1043            .collect_vec();
1044        StateTable::from_table_catalog(
1045            &gen_pbtable(
1046                TableId::new(table_id),
1047                column_descs,
1048                order_types.to_vec(),
1049                pk_indices.to_vec(),
1050                0,
1051            ),
1052            mem_state.clone(),
1053            None,
1054        )
1055        .await
1056    }
1057
1058    async fn create_executor<const T: AsOfJoinTypePrimitive>(
1059        asof_desc: AsOfDesc,
1060        use_cache: bool,
1061    ) -> (MessageSender, MessageSender, BoxedMessageStream) {
1062        create_executor_with_metrics::<T>(
1063            asof_desc,
1064            use_cache,
1065            Arc::new(StreamingMetrics::unused()),
1066        )
1067        .await
1068    }
1069
1070    async fn create_executor_with_metrics<const T: AsOfJoinTypePrimitive>(
1071        asof_desc: AsOfDesc,
1072        use_cache: bool,
1073        metrics: Arc<StreamingMetrics>,
1074    ) -> (MessageSender, MessageSender, BoxedMessageStream) {
1075        create_executor_with_store::<T>(asof_desc, use_cache, metrics, MemoryStateStore::new())
1076            .await
1077    }
1078
1079    /// The state table of a side, with pk (join key, inequality column, stream key).
1080    async fn create_side_state_table(
1081        mem_state: MemoryStateStore,
1082        inequality_idx: usize,
1083        table_id: u32,
1084    ) -> StateTable<MemoryStateStore> {
1085        create_in_memory_state_table(
1086            mem_state,
1087            &[DataType::Int64, DataType::Int64, DataType::Int64],
1088            &[
1089                OrderType::ascending(),
1090                OrderType::ascending(),
1091                OrderType::ascending(),
1092            ],
1093            &[0, inequality_idx, 1],
1094            table_id,
1095        )
1096        .await
1097    }
1098
1099    async fn create_executor_with_store<const T: AsOfJoinTypePrimitive>(
1100        asof_desc: AsOfDesc,
1101        use_cache: bool,
1102        metrics: Arc<StreamingMetrics>,
1103        mem_state: MemoryStateStore,
1104    ) -> (MessageSender, MessageSender, BoxedMessageStream) {
1105        let schema = Schema {
1106            fields: vec![
1107                Field::unnamed(DataType::Int64), // join key
1108                Field::unnamed(DataType::Int64),
1109                Field::unnamed(DataType::Int64),
1110            ],
1111        };
1112        let (tx_l, source_l) = MockSource::channel();
1113        let source_l = source_l.into_executor(schema.clone(), vec![1]);
1114        let (tx_r, source_r) = MockSource::channel();
1115        let source_r = source_r.into_executor(schema, vec![1]);
1116        let params_l = JoinParams::new(vec![0], vec![1]);
1117        let params_r = JoinParams::new(vec![0], vec![1]);
1118
1119        let state_l = create_side_state_table(mem_state.clone(), asof_desc.left_idx, 0).await;
1120        let state_r = create_side_state_table(mem_state, asof_desc.right_idx, 1).await;
1121
1122        let schema: Schema = [source_l.schema().fields(), source_r.schema().fields()]
1123            .concat()
1124            .into_iter()
1125            .collect();
1126        let schema_len = schema.len();
1127        let info = ExecutorInfo::for_test(schema, vec![1], "AsOfJoinExecutor".to_owned(), 0);
1128
1129        let executor = AsOfJoinExecutor::<MemoryStateStore, T, AsOfCpuEncoding>::new(
1130            ActorContext::for_test(123),
1131            info,
1132            source_l,
1133            source_r,
1134            params_l,
1135            params_r,
1136            vec![false],
1137            (0..schema_len).collect_vec(),
1138            state_l,
1139            state_r,
1140            Arc::new(AtomicU64::new(0)),
1141            metrics,
1142            1024,
1143            asof_desc,
1144            use_cache,
1145            2048, // high_join_amplification_threshold
1146        );
1147        (tx_l, tx_r, executor.boxed().execute())
1148    }
1149
1150    fn join_matched_sample_count(registry: &Registry, table_id: &str) -> u64 {
1151        registry
1152            .gather()
1153            .iter()
1154            .find(|metric_family| metric_family.name() == "stream_join_matched_join_keys")
1155            .and_then(|metric_family| {
1156                metric_family.get_metric().iter().find(|metric| {
1157                    get_label::<String>(metric, "table_id").as_deref() == Some(table_id)
1158                })
1159            })
1160            .map(|metric| metric.get_histogram().get_sample_count())
1161            .unwrap_or(0)
1162    }
1163
1164    #[tokio::test]
1165    async fn test_asof_join_records_null_equi_key_probe_metrics() -> StreamExecutorResult<()> {
1166        let asof_desc = AsOfDesc {
1167            left_idx: 1,
1168            right_idx: 1,
1169            inequality_type: AsOfInequalityType::Lt,
1170        };
1171        let registry = Registry::new();
1172        let metrics = Arc::new(StreamingMetrics::new(&registry, MetricLevel::Debug));
1173        let (mut tx_l, mut tx_r, mut hash_join) =
1174            create_executor_with_metrics::<{ AsOfJoinType::Inner }>(asof_desc, false, metrics)
1175                .await;
1176
1177        tx_l.push_barrier(test_epoch(1), false);
1178        tx_r.push_barrier(test_epoch(1), false);
1179        hash_join.next_unwrap_ready_barrier()?;
1180
1181        tx_l.push_chunk(StreamChunk::from_pretty(
1182            "  I I I
1183             + . 10 1",
1184        ));
1185        hash_join.next_unwrap_pending();
1186
1187        tx_r.push_chunk(StreamChunk::from_pretty(
1188            "  I I I
1189             + . 20 2",
1190        ));
1191        hash_join.next_unwrap_pending();
1192
1193        assert_eq!(join_matched_sample_count(&registry, "0"), 1);
1194        assert_eq!(join_matched_sample_count(&registry, "1"), 1);
1195
1196        Ok(())
1197    }
1198
1199    #[tokio::test]
1200    async fn test_asof_inner_join() -> StreamExecutorResult<()> {
1201        test_asof_inner_join_impl(false).await
1202    }
1203
1204    #[tokio::test]
1205    async fn test_asof_inner_join_with_cache() -> StreamExecutorResult<()> {
1206        test_asof_inner_join_impl(true).await
1207    }
1208
1209    async fn test_asof_inner_join_impl(use_cache: bool) -> StreamExecutorResult<()> {
1210        let asof_desc = AsOfDesc {
1211            left_idx: 0,
1212            right_idx: 2,
1213            inequality_type: AsOfInequalityType::Lt,
1214        };
1215
1216        let chunk_l1 = StreamChunk::from_pretty(
1217            "  I I I
1218             + 1 4 7
1219             + 2 5 8
1220             + 3 6 9",
1221        );
1222        let chunk_l2 = StreamChunk::from_pretty(
1223            "  I I I
1224             + 3 8 1
1225             - 3 8 1",
1226        );
1227        let chunk_r1 = StreamChunk::from_pretty(
1228            "  I I I
1229             + 2 1 7
1230             + 2 2 1
1231             + 2 3 4
1232             + 2 4 2
1233             + 6 1 9
1234             + 6 2 9",
1235        );
1236        let chunk_r2 = StreamChunk::from_pretty(
1237            "  I I I
1238             - 2 3 4",
1239        );
1240        let chunk_r3 = StreamChunk::from_pretty(
1241            "  I I I
1242             + 2 3 3",
1243        );
1244        let chunk_l3 = StreamChunk::from_pretty(
1245            "  I I I
1246             - 2 5 8",
1247        );
1248        let chunk_l4 = StreamChunk::from_pretty(
1249            "  I I I
1250             + 6 3 1
1251             + 6 4 1",
1252        );
1253        let chunk_r4 = StreamChunk::from_pretty(
1254            "  I I I
1255             - 6 1 9",
1256        );
1257
1258        let (mut tx_l, mut tx_r, mut hash_join) =
1259            create_executor::<{ AsOfJoinType::Inner }>(asof_desc, use_cache).await;
1260
1261        // push the init barrier for left and right
1262        tx_l.push_barrier(test_epoch(1), false);
1263        tx_r.push_barrier(test_epoch(1), false);
1264        hash_join.next_unwrap_ready_barrier()?;
1265
1266        // push the 1st left chunk
1267        tx_l.push_chunk(chunk_l1);
1268        hash_join.next_unwrap_pending();
1269
1270        // push the init barrier for left and right
1271        tx_l.push_barrier(test_epoch(2), false);
1272        tx_r.push_barrier(test_epoch(2), false);
1273        hash_join.next_unwrap_ready_barrier()?;
1274
1275        // push the 2nd left chunk
1276        tx_l.push_chunk(chunk_l2);
1277        hash_join.next_unwrap_pending();
1278
1279        // push the 1st right chunk
1280        tx_r.push_chunk(chunk_r1);
1281        let chunk = hash_join.next_unwrap_ready_chunk()?;
1282        assert_eq!(
1283            chunk,
1284            StreamChunk::from_pretty(
1285                " I I I I I I
1286                + 2 5 8 2 1 7
1287                - 2 5 8 2 1 7
1288                + 2 5 8 2 3 4"
1289            )
1290        );
1291
1292        // push the 2nd right chunk
1293        tx_r.push_chunk(chunk_r2);
1294        let chunk = hash_join.next_unwrap_ready_chunk()?;
1295        assert_eq!(
1296            chunk,
1297            StreamChunk::from_pretty(
1298                " I I I I I I
1299                - 2 5 8 2 3 4
1300                + 2 5 8 2 1 7"
1301            )
1302        );
1303
1304        // push the 3rd right chunk
1305        tx_r.push_chunk(chunk_r3);
1306        let chunk = hash_join.next_unwrap_ready_chunk()?;
1307        assert_eq!(
1308            chunk,
1309            StreamChunk::from_pretty(
1310                " I I I I I I
1311                - 2 5 8 2 1 7
1312                + 2 5 8 2 3 3"
1313            )
1314        );
1315
1316        // push the 3rd left chunk
1317        tx_l.push_chunk(chunk_l3);
1318        let chunk = hash_join.next_unwrap_ready_chunk()?;
1319        assert_eq!(
1320            chunk,
1321            StreamChunk::from_pretty(
1322                " I I I I I I
1323                - 2 5 8 2 3 3"
1324            )
1325        );
1326
1327        // push the 4th left chunk
1328        tx_l.push_chunk(chunk_l4);
1329        let chunk = hash_join.next_unwrap_ready_chunk()?;
1330        assert_eq!(
1331            chunk,
1332            StreamChunk::from_pretty(
1333                " I I I I I I
1334                + 6 3 1 6 1 9
1335                + 6 4 1 6 1 9"
1336            )
1337        );
1338
1339        // push the 4th right chunk
1340        tx_r.push_chunk(chunk_r4);
1341        let chunk = hash_join.next_unwrap_ready_chunk()?;
1342        assert_eq!(
1343            chunk,
1344            StreamChunk::from_pretty(
1345                " I I I I I I
1346                - 6 3 1 6 1 9
1347                + 6 3 1 6 2 9
1348                - 6 4 1 6 1 9
1349                + 6 4 1 6 2 9"
1350            )
1351        );
1352
1353        Ok(())
1354    }
1355
1356    #[tokio::test]
1357    async fn test_asof_left_outer_join() -> StreamExecutorResult<()> {
1358        test_asof_left_outer_join_impl(false).await
1359    }
1360
1361    #[tokio::test]
1362    async fn test_asof_left_outer_join_with_cache() -> StreamExecutorResult<()> {
1363        test_asof_left_outer_join_impl(true).await
1364    }
1365
1366    async fn test_asof_left_outer_join_impl(use_cache: bool) -> StreamExecutorResult<()> {
1367        let asof_desc = AsOfDesc {
1368            left_idx: 1,
1369            right_idx: 2,
1370            inequality_type: AsOfInequalityType::Ge,
1371        };
1372
1373        let chunk_l1 = StreamChunk::from_pretty(
1374            "  I I I
1375             + 1 4 7
1376             + 2 5 8
1377             + 3 6 9",
1378        );
1379        let chunk_l2 = StreamChunk::from_pretty(
1380            "  I I I
1381             + 3 8 1
1382             - 3 8 1",
1383        );
1384        let chunk_r1 = StreamChunk::from_pretty(
1385            "  I I I
1386             + 2 3 4
1387             + 2 2 5
1388             + 2 1 5
1389             + 6 1 8
1390             + 6 2 9",
1391        );
1392        let chunk_r2 = StreamChunk::from_pretty(
1393            "  I I I
1394             - 2 3 4
1395             - 2 1 5
1396             - 2 2 5",
1397        );
1398        let chunk_l3 = StreamChunk::from_pretty(
1399            "  I I I
1400             + 6 8 9",
1401        );
1402        let chunk_r3 = StreamChunk::from_pretty(
1403            "  I I I
1404             - 6 1 8",
1405        );
1406
1407        let (mut tx_l, mut tx_r, mut hash_join) =
1408            create_executor::<{ AsOfJoinType::LeftOuter }>(asof_desc, use_cache).await;
1409
1410        // push the init barrier for left and right
1411        tx_l.push_barrier(test_epoch(1), false);
1412        tx_r.push_barrier(test_epoch(1), false);
1413        hash_join.next_unwrap_ready_barrier()?;
1414
1415        // push the 1st left chunk
1416        tx_l.push_chunk(chunk_l1);
1417        let chunk = hash_join.next_unwrap_ready_chunk()?;
1418        assert_eq!(
1419            chunk,
1420            StreamChunk::from_pretty(
1421                " I I I I I I
1422                + 1 4 7 . . .
1423                + 2 5 8 . . .
1424                + 3 6 9 . . ."
1425            )
1426        );
1427
1428        // push the init barrier for left and right
1429        tx_l.push_barrier(test_epoch(2), false);
1430        tx_r.push_barrier(test_epoch(2), false);
1431        hash_join.next_unwrap_ready_barrier()?;
1432
1433        // push the 2nd left chunk
1434        tx_l.push_chunk(chunk_l2);
1435        let chunk = hash_join.next_unwrap_ready_chunk()?;
1436        assert_eq!(
1437            chunk,
1438            StreamChunk::from_pretty(
1439                " I I I I I I
1440                + 3 8 1 . . . D
1441                - 3 8 1 . . . D"
1442            )
1443        );
1444
1445        // push the 1st right chunk
1446        tx_r.push_chunk(chunk_r1);
1447        let chunk = hash_join.next_unwrap_ready_chunk()?;
1448        assert_eq!(
1449            chunk,
1450            StreamChunk::from_pretty(
1451                " I I I I I I
1452                - 2 5 8 . . .
1453                + 2 5 8 2 3 4
1454                - 2 5 8 2 3 4
1455                + 2 5 8 2 2 5
1456                - 2 5 8 2 2 5
1457                + 2 5 8 2 1 5"
1458            )
1459        );
1460
1461        // push the 2nd right chunk
1462        tx_r.push_chunk(chunk_r2);
1463        let chunk = hash_join.next_unwrap_ready_chunk()?;
1464        assert_eq!(
1465            chunk,
1466            StreamChunk::from_pretty(
1467                " I I I I I I
1468                - 2 5 8 2 1 5
1469                + 2 5 8 2 2 5
1470                - 2 5 8 2 2 5
1471                + 2 5 8 . . ."
1472            )
1473        );
1474
1475        // push the 3rd left chunk
1476        tx_l.push_chunk(chunk_l3);
1477        let chunk = hash_join.next_unwrap_ready_chunk()?;
1478        assert_eq!(
1479            chunk,
1480            StreamChunk::from_pretty(
1481                " I I I I I I
1482                + 6 8 9 6 1 8"
1483            )
1484        );
1485
1486        // push the 3rd right chunk
1487        tx_r.push_chunk(chunk_r3);
1488        let chunk = hash_join.next_unwrap_ready_chunk()?;
1489        assert_eq!(
1490            chunk,
1491            StreamChunk::from_pretty(
1492                " I I I I I I
1493                - 6 8 9 6 1 8
1494                + 6 8 9 . . ."
1495            )
1496        );
1497        Ok(())
1498    }
1499
1500    #[tokio::test]
1501    async fn test_asof_join_state_cleaning() -> StreamExecutorResult<()> {
1502        for use_cache in [false, true] {
1503            let store = MemoryStateStore::new();
1504            // Inner join on `k` with `l.c1 >= r.c2`.
1505            let create_executor = || {
1506                create_executor_with_store::<{ AsOfJoinType::Inner }>(
1507                    AsOfDesc {
1508                        left_idx: 1,
1509                        right_idx: 2,
1510                        inequality_type: AsOfInequalityType::Ge,
1511                    },
1512                    use_cache,
1513                    Arc::new(StreamingMetrics::unused()),
1514                    store.clone(),
1515                )
1516            };
1517            let (mut tx_l, mut tx_r, mut join) = create_executor().await;
1518            tx_l.push_barrier(test_epoch(1), false);
1519            tx_r.push_barrier(test_epoch(1), false);
1520            join.next_unwrap_ready_barrier()?;
1521            tx_r.push_chunk(StreamChunk::from_pretty(" I I I\n + 5 1 50"));
1522            join.next_unwrap_pending();
1523            tx_l.push_chunk(StreamChunk::from_pretty(" I I I\n + 5 100 0"));
1524            assert_eq!(
1525                join.next_unwrap_ready_chunk()?,
1526                StreamChunk::from_pretty(" I I I I I I\n + 5 100 0 5 1 50")
1527            );
1528
1529            tx_l.push_int64_watermark(0, 10);
1530            tx_l.push_barrier(test_epoch(2), false);
1531            tx_r.push_barrier(test_epoch(2), false);
1532            join.next_unwrap_ready_barrier()?;
1533            if use_cache {
1534                // Recover, so that the cache does not hide the state. The upstream sends the left
1535                // watermark again.
1536                (tx_l, tx_r, join) = create_executor().await;
1537                tx_l.push_barrier(test_epoch(2), false);
1538                tx_r.push_barrier(test_epoch(2), false);
1539                join.next_unwrap_ready_barrier()?;
1540                tx_l.push_int64_watermark(0, 10);
1541            }
1542
1543            // A right row of a key below the left watermark replaces the match.
1544            tx_r.push_chunk(StreamChunk::from_pretty(" I I I\n + 5 2 60"));
1545            assert_eq!(
1546                join.next_unwrap_ready_chunk()?,
1547                StreamChunk::from_pretty(
1548                    " I I I I I I
1549                    - 5 100 0 5 1 50
1550                    + 5 100 0 5 2 60"
1551                ),
1552                "use_cache: {use_cache}"
1553            );
1554
1555            // A left row of a key below the right watermark is deleted.
1556            tx_r.push_chunk(StreamChunk::from_pretty(" I I I\n + 15 3 50"));
1557            join.next_unwrap_pending();
1558            tx_l.push_chunk(StreamChunk::from_pretty(" I I I\n + 15 101 0"));
1559            assert_eq!(
1560                join.next_unwrap_ready_chunk()?,
1561                StreamChunk::from_pretty(" I I I I I I\n + 15 101 0 15 3 50")
1562            );
1563            tx_r.push_int64_watermark(0, 20);
1564            for _ in 0..2 {
1565                assert_eq!(
1566                    join.next_unwrap_ready_watermark()?.val,
1567                    ScalarImpl::Int64(10)
1568                );
1569            }
1570            tx_l.push_barrier(test_epoch(3), false);
1571            tx_r.push_barrier(test_epoch(3), false);
1572            join.next_unwrap_ready_barrier()?;
1573            tx_l.push_chunk(StreamChunk::from_pretty(" I I I\n - 15 101 0"));
1574            assert_eq!(
1575                join.next_unwrap_ready_chunk()?,
1576                StreamChunk::from_pretty(" I I I I I I\n - 15 101 0 15 3 50")
1577            );
1578            tx_l.push_barrier(test_epoch(4), false);
1579            tx_r.push_barrier(test_epoch(4), false);
1580            join.next_unwrap_ready_barrier()?;
1581
1582            // Once both watermarks pass all keys, both tables are cleaned.
1583            tx_l.push_int64_watermark(0, 20);
1584            for _ in 0..2 {
1585                assert_eq!(
1586                    join.next_unwrap_ready_watermark()?.val,
1587                    ScalarImpl::Int64(20)
1588                );
1589            }
1590            tx_l.push_barrier(test_epoch(5), false);
1591            tx_r.push_barrier(test_epoch(5), false);
1592            join.next_unwrap_ready_barrier()?;
1593            for (inequality_idx, table_id) in [(1, 0), (2, 1)] {
1594                let mut table =
1595                    create_side_state_table(store.clone(), inequality_idx, table_id).await;
1596                table
1597                    .init_epoch(EpochPair::new_test_epoch(test_epoch(5)))
1598                    .await?;
1599                let sub_range: &(Bound<OwnedRow>, Bound<OwnedRow>) =
1600                    &(Bound::Unbounded, Bound::Unbounded);
1601                let rows: Vec<OwnedRow> = table
1602                    .iter_with_prefix(row::empty(), sub_range, PrefetchOptions::default())
1603                    .await?
1604                    .try_collect()
1605                    .await?;
1606                assert!(rows.is_empty(), "use_cache: {use_cache}, table: {table_id}");
1607            }
1608        }
1609        Ok(())
1610    }
1611}