Skip to main content

risingwave_stream/executor/mview/
cache.rs

1// Copyright 2025 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::HashSet;
16use std::ops::Deref as _;
17
18use bytes::Bytes;
19use futures::{StreamExt as _, stream};
20use itertools::Itertools as _;
21use risingwave_common::array::{Op, StreamChunk};
22use risingwave_common::catalog::{ConflictBehavior, checked_conflict_behaviors};
23use risingwave_common::row::{CompactedRow, OwnedRow, Row, RowExt as _};
24use risingwave_common::types::ScalarImpl;
25use risingwave_common::util::iter_util::ZipEqFast as _;
26use risingwave_common::util::sort_util::{OrderType, cmp_datum};
27use risingwave_common::util::value_encoding::{BasicSerde, ValueRowSerializer as _};
28use risingwave_storage::StateStore;
29use risingwave_storage::row_serde::value_serde::ValueRowSerde;
30
31use crate::cache::ManagedLruCache;
32use crate::common::metrics::MetricsInfo;
33use crate::common::table::state_table::StateTableInner;
34use crate::executor::StreamExecutorResult;
35use crate::executor::monitor::MaterializeCacheMetrics;
36use crate::task::AtomicU64Ref;
37
38/// A cache for materialize executors.
39pub struct MaterializeCache {
40    lru_cache: ManagedLruCache<Vec<u8>, CacheValue>,
41    row_serde: BasicSerde,
42    version_column_indices: Vec<u32>,
43    conflict_behavior: ConflictBehavior,
44    toastable_column_indices: Option<Vec<usize>>,
45    metrics: MaterializeCacheMetrics,
46}
47
48type CacheValue = Option<CompactedRow>;
49type ChangeBuffer = crate::common::change_buffer::ChangeBuffer<Vec<u8>, OwnedRow>;
50
51/// Check whether the given row is below the latest watermark of the table. If so, we may want to
52/// treat them as non-existent. This extra step is necessary for TTL-ed tables because:
53///
54/// - the storage may clean expired rows asynchronously
55/// - an expired row may still be in the cache while we expect a consistent view
56///
57/// The watermark is forwarded downstream once it is fed into the table, so the rows below it are
58/// expired before the commit.
59fn row_below_watermark<S: StateStore, SD: ValueRowSerde>(
60    row_serde: &BasicSerde,
61    table: &StateTableInner<S, SD>,
62    row: &CompactedRow,
63) -> StreamExecutorResult<bool> {
64    let Some(clean_watermark_index) = table.clean_watermark_index else {
65        return Ok(false);
66    };
67    let Some(watermark) = table
68        .get_pending_watermark()
69        .or(table.get_committed_watermark())
70    else {
71        return Ok(false);
72    };
73    let deserialized = row_serde.deserializer.deserialize(row.row.clone())?;
74    Ok(cmp_datum(
75        deserialized.datum_at(clean_watermark_index),
76        Some(watermark.as_scalar_ref_impl()),
77        OrderType::ascending(),
78    ) == std::cmp::Ordering::Less)
79}
80
81impl MaterializeCache {
82    /// Create a new `MaterializeCache`.
83    ///
84    /// Returns `None` if the conflict behavior is `NoCheck`.
85    pub fn new(
86        watermark_sequence: AtomicU64Ref,
87        metrics_info: MetricsInfo,
88        row_serde: BasicSerde,
89        version_column_indices: Vec<u32>,
90        conflict_behavior: ConflictBehavior,
91        toastable_column_indices: Option<Vec<usize>>,
92        materialize_cache_metrics: MaterializeCacheMetrics,
93    ) -> Option<Self> {
94        match conflict_behavior {
95            checked_conflict_behaviors!() => {}
96            ConflictBehavior::NoCheck => {
97                assert!(
98                    toastable_column_indices.is_none(),
99                    "when there are toastable columns, conflict handling must be enabled"
100                );
101                return None;
102            }
103        }
104
105        let lru_cache: ManagedLruCache<Vec<u8>, CacheValue> =
106            ManagedLruCache::unbounded(watermark_sequence, metrics_info);
107        Some(Self {
108            lru_cache,
109            row_serde,
110            version_column_indices,
111            conflict_behavior,
112            toastable_column_indices,
113            metrics: materialize_cache_metrics,
114        })
115    }
116
117    /// First populate the cache from `table`, and then calculate a [`ChangeBuffer`].
118    /// `table` will not be written in this method.
119    pub async fn handle_new<S: StateStore, SD: ValueRowSerde>(
120        &mut self,
121        chunk: StreamChunk,
122        table: &StateTableInner<S, SD>,
123    ) -> StreamExecutorResult<ChangeBuffer> {
124        if table.value_indices().is_some() {
125            // TODO(st1page): when materialize partial columns(), we should
126            // construct some columns in the pk
127            unimplemented!("materialize cache cannot handle conflicts with partial table values");
128        };
129
130        let (data_chunk, ops) = chunk.clone().into_parts();
131        let values = data_chunk.serialize();
132
133        let key_chunk = data_chunk.project(table.pk_indices());
134
135        let pks = {
136            let mut pks = vec![vec![]; data_chunk.capacity()];
137            key_chunk
138                .rows_with_holes()
139                .zip_eq_fast(pks.iter_mut())
140                .for_each(|(r, vnode_and_pk)| {
141                    if let Some(r) = r {
142                        table.pk_serde().serialize(r, vnode_and_pk);
143                    }
144                });
145            pks
146        };
147        let (_, vis) = key_chunk.into_parts();
148        let row_ops = ops
149            .iter()
150            .zip_eq_fast(pks.into_iter())
151            .zip_eq_fast(values.into_iter())
152            .zip_eq_fast(vis.iter())
153            .filter_map(|(((op, k), v), vis)| vis.then_some((*op, k, v)))
154            .collect_vec();
155
156        self.handle_inner(row_ops, table).await
157    }
158
159    async fn handle_inner<S: StateStore, SD: ValueRowSerde>(
160        &mut self,
161        row_ops: Vec<(Op, Vec<u8>, Bytes)>,
162        table: &StateTableInner<S, SD>,
163    ) -> StreamExecutorResult<ChangeBuffer> {
164        let key_set: HashSet<Box<[u8]>> = row_ops
165            .iter()
166            .map(|(_, k, _)| k.as_slice().into())
167            .collect();
168
169        // Populate the LRU cache with the keys in input chunk.
170        // For new keys, row values are set to None.
171        self.fetch_keys(key_set.iter().map(|v| v.deref()), table)
172            .await?;
173
174        let mut change_buffer = ChangeBuffer::new();
175        let row_serde = self.row_serde.clone();
176        let version_column_indices = self.version_column_indices.clone();
177        for (op, key, row) in row_ops {
178            // Use a macro instead of method to workaround partial borrow.
179            macro_rules! get_expected {
180                () => {
181                    self.lru_cache.get(&key).unwrap_or_else(|| {
182                        panic!(
183                            "the key {:?} has not been fetched in the materialize executor's cache",
184                            key
185                        )
186                    })
187                };
188            }
189
190            match op {
191                Op::Insert | Op::UpdateInsert => {
192                    let Some(old_row) = get_expected!() else {
193                        // not exists before, meaning no conflict, simply insert
194                        let new_row_deserialized =
195                            row_serde.deserializer.deserialize(row.clone())?;
196                        change_buffer.insert(key.clone(), new_row_deserialized);
197                        self.lru_cache.put(key, Some(CompactedRow { row }));
198                        continue;
199                    };
200
201                    // now conflict happens, handle it according to the specified behavior
202                    match self.conflict_behavior {
203                        ConflictBehavior::Overwrite => {
204                            let old_row_deserialized =
205                                row_serde.deserializer.deserialize(old_row.row.clone())?;
206                            let new_row_deserialized =
207                                row_serde.deserializer.deserialize(row.clone())?;
208
209                            let need_overwrite = if !version_column_indices.is_empty() {
210                                versions_are_newer_or_equal(
211                                    &old_row_deserialized,
212                                    &new_row_deserialized,
213                                    &version_column_indices,
214                                )
215                            } else {
216                                // no version column specified, just overwrite
217                                true
218                            };
219
220                            if need_overwrite {
221                                if let Some(toastable_indices) = &self.toastable_column_indices {
222                                    // For TOAST-able columns, replace Debezium's unavailable value placeholder with old row values.
223                                    let final_row = toast::handle_toast_columns_for_postgres_cdc(
224                                        &old_row_deserialized,
225                                        &new_row_deserialized,
226                                        toastable_indices,
227                                    );
228
229                                    change_buffer.update(
230                                        key.clone(),
231                                        old_row_deserialized,
232                                        final_row.clone(),
233                                    );
234                                    let final_row_bytes =
235                                        Bytes::from(row_serde.serializer.serialize(final_row));
236                                    self.lru_cache.put(
237                                        key.clone(),
238                                        Some(CompactedRow {
239                                            row: final_row_bytes,
240                                        }),
241                                    );
242                                } else {
243                                    // No TOAST columns, use the original row bytes directly to avoid unnecessary serialization
244                                    change_buffer.update(
245                                        key.clone(),
246                                        old_row_deserialized,
247                                        new_row_deserialized,
248                                    );
249                                    self.lru_cache
250                                        .put(key.clone(), Some(CompactedRow { row: row.clone() }));
251                                }
252                            };
253                        }
254                        ConflictBehavior::IgnoreConflict => {
255                            // ignore conflict, do nothing
256                        }
257                        ConflictBehavior::DoUpdateIfNotNull => {
258                            // In this section, we compare the new row and old row column by column and perform `DoUpdateIfNotNull` replacement.
259
260                            let old_row_deserialized =
261                                row_serde.deserializer.deserialize(old_row.row.clone())?;
262                            let new_row_deserialized =
263                                row_serde.deserializer.deserialize(row.clone())?;
264                            let need_overwrite = if !version_column_indices.is_empty() {
265                                versions_are_newer_or_equal(
266                                    &old_row_deserialized,
267                                    &new_row_deserialized,
268                                    &version_column_indices,
269                                )
270                            } else {
271                                true
272                            };
273
274                            if need_overwrite {
275                                let mut row_deserialized_vec =
276                                    old_row_deserialized.clone().into_inner().into_vec();
277                                replace_if_not_null(
278                                    &mut row_deserialized_vec,
279                                    new_row_deserialized.clone(),
280                                );
281                                let mut updated_row = OwnedRow::new(row_deserialized_vec);
282
283                                // Apply TOAST column fix for CDC tables with TOAST columns
284                                if let Some(toastable_indices) = &self.toastable_column_indices {
285                                    // Note: we need to use old_row_deserialized again, but it was moved above
286                                    // So we re-deserialize the old row
287                                    let old_row_deserialized_again =
288                                        row_serde.deserializer.deserialize(old_row.row.clone())?;
289                                    updated_row = toast::handle_toast_columns_for_postgres_cdc(
290                                        &old_row_deserialized_again,
291                                        &updated_row,
292                                        toastable_indices,
293                                    );
294                                }
295
296                                change_buffer.update(
297                                    key.clone(),
298                                    old_row_deserialized,
299                                    updated_row.clone(),
300                                );
301                                let updated_row_bytes =
302                                    Bytes::from(row_serde.serializer.serialize(updated_row));
303                                self.lru_cache.put(
304                                    key.clone(),
305                                    Some(CompactedRow {
306                                        row: updated_row_bytes,
307                                    }),
308                                );
309                            }
310                        }
311                        ConflictBehavior::NoCheck => unreachable!(),
312                    };
313                }
314
315                Op::UpdateDelete
316                    if matches!(
317                        self.conflict_behavior,
318                        ConflictBehavior::Overwrite | ConflictBehavior::DoUpdateIfNotNull
319                    ) =>
320                {
321                    // For `UpdateDelete`s, we skip processing them but directly handle the following `UpdateInsert`
322                    // instead. This is because...
323                    //
324                    // - For `Overwrite`, we only care about the new row.
325                    // - For `DoUpdateIfNotNull`, we don't want the whole row to be deleted, but instead perform
326                    //   column-wise replacement when handling the `UpdateInsert`.
327                    //
328                    // However, for `IgnoreConflict`, we still need to delete the old row first, otherwise the row
329                    // cannot be updated at all.
330                }
331
332                Op::Delete | Op::UpdateDelete => {
333                    if let Some(old_row) = get_expected!() {
334                        let old_row_deserialized =
335                            row_serde.deserializer.deserialize(old_row.row.clone())?;
336                        change_buffer.delete(key.clone(), old_row_deserialized);
337                        // put a None into the cache to represent deletion
338                        self.lru_cache.put(key, None);
339                    } else {
340                        // delete a non-existent value
341                        // this is allowed in the case of mview conflict, so ignore
342                    }
343                }
344            }
345        }
346        Ok(change_buffer)
347    }
348
349    async fn fetch_keys<'a, S: StateStore, SD: ValueRowSerde>(
350        &mut self,
351        keys: impl Iterator<Item = &'a [u8]>,
352        table: &StateTableInner<S, SD>,
353    ) -> StreamExecutorResult<()> {
354        let mut futures = vec![];
355        for key in keys {
356            self.metrics.materialize_cache_total_count.inc();
357
358            if let Some(cached) = self.lru_cache.get(key) {
359                self.metrics.materialize_cache_hit_count.inc();
360                if let Some(row) = cached {
361                    if row_below_watermark(&self.row_serde, table, row)? {
362                        self.lru_cache.put(key.to_vec(), None);
363                    } else {
364                        self.metrics.materialize_data_exist_count.inc();
365                    }
366                }
367                continue;
368            }
369
370            let row_serde = self.row_serde.clone();
371            futures.push(async move {
372                let key_row = table.pk_serde().deserialize(key).unwrap();
373                let row = table.get_row(key_row).await?.map(CompactedRow::from);
374                let row = match row {
375                    Some(row) if row_below_watermark(&row_serde, table, &row)? => None,
376                    other => other,
377                };
378                StreamExecutorResult::Ok((key.to_vec(), row))
379            });
380        }
381
382        let mut buffered = stream::iter(futures).buffer_unordered(10).fuse();
383        while let Some(result) = buffered.next().await {
384            let (key, row) = result?;
385            if row.is_some() {
386                self.metrics.materialize_data_exist_count.inc();
387            }
388            // for keys that are not in the table, `value` is None
389            match self.conflict_behavior {
390                checked_conflict_behaviors!() => self.lru_cache.put(key, row),
391                _ => unreachable!(),
392            };
393        }
394
395        Ok(())
396    }
397
398    /// Drop the entries of rows deleted from `table` without going through [`Self::handle_new`].
399    /// Entries are removed instead of set to `None` so mass deletes leave no tombstones behind.
400    pub fn invalidate_rows<S: StateStore, SD: ValueRowSerde>(
401        &mut self,
402        rows: impl IntoIterator<Item = impl Row>,
403        table: &StateTableInner<S, SD>,
404    ) {
405        for row in rows {
406            let key = row
407                .project(table.pk_indices())
408                .memcmp_serialize(table.pk_serde());
409            self.lru_cache.remove(&key);
410        }
411    }
412
413    /// Evict the LRU cache entries that are lower than the watermark.
414    pub fn evict(&mut self) {
415        self.lru_cache.evict()
416    }
417
418    /// Clear the LRU cache.
419    pub fn clear(&mut self) {
420        self.lru_cache.clear()
421    }
422}
423
424/// Replace columns in an existing row with the corresponding columns in a replacement row, if the
425/// column value in the replacement row is not null.
426///
427/// # Example
428///
429/// ```ignore
430/// let mut row = vec![Some(1), None, Some(3)];
431/// let replacement = vec![Some(10), Some(20), None];
432/// replace_if_not_null(&mut row, replacement);
433/// ```
434///
435/// After the call, `row` will be `[Some(10), Some(20), Some(3)]`.
436fn replace_if_not_null(row: &mut Vec<Option<ScalarImpl>>, replacement: OwnedRow) {
437    for (old_col, new_col) in row.iter_mut().zip_eq_fast(replacement) {
438        if let Some(new_value) = new_col {
439            *old_col = Some(new_value);
440        }
441    }
442}
443
444/// Compare multiple version columns lexicographically.
445/// Returns true if `new_row` has a newer or equal version compared to `old_row`.
446fn versions_are_newer_or_equal(
447    old_row: &OwnedRow,
448    new_row: &OwnedRow,
449    version_column_indices: &[u32],
450) -> bool {
451    if version_column_indices.is_empty() {
452        // No version columns specified, always consider new version as newer
453        return true;
454    }
455
456    for &idx in version_column_indices {
457        let old_value = old_row.datum_at(idx as usize);
458        let new_value = new_row.datum_at(idx as usize);
459
460        match cmp_datum(old_value, new_value, OrderType::ascending_nulls_first()) {
461            std::cmp::Ordering::Less => return true,     // new is newer
462            std::cmp::Ordering::Greater => return false, // old is newer
463            std::cmp::Ordering::Equal => continue,       // equal, check next column
464        }
465    }
466
467    // All version columns are equal, consider new version as equal (should overwrite)
468    true
469}
470
471/// TOAST column handling for CDC tables with TOAST columns.
472mod toast {
473    use risingwave_common::types::{DEBEZIUM_UNAVAILABLE_VALUE, DEBEZIUM_UNAVAILABLE_VECTOR_ELEM};
474
475    use super::*;
476
477    /// Fast string comparison to check if a string equals `DEBEZIUM_UNAVAILABLE_VALUE`.
478    /// Optimized by checking length first to avoid expensive string comparison.
479    fn is_unavailable_value_str(s: &str) -> bool {
480        s.len() == DEBEZIUM_UNAVAILABLE_VALUE.len() && s == DEBEZIUM_UNAVAILABLE_VALUE
481    }
482
483    /// Check if a datum represents Debezium's unavailable value placeholder.
484    /// This function handles both scalar types and one-dimensional arrays.
485    fn is_debezium_unavailable_value(
486        datum: &Option<risingwave_common::types::ScalarRefImpl<'_>>,
487    ) -> bool {
488        match datum {
489            Some(risingwave_common::types::ScalarRefImpl::Utf8(val)) => {
490                is_unavailable_value_str(val)
491            }
492            Some(risingwave_common::types::ScalarRefImpl::Jsonb(jsonb_ref)) => {
493                // For jsonb type, check if it's a string containing the unavailable value
494                jsonb_ref
495                    .as_str()
496                    .map(is_unavailable_value_str)
497                    .unwrap_or(false)
498            }
499            Some(risingwave_common::types::ScalarRefImpl::Bytea(bytea)) => {
500                // For bytea type, we need to check if it contains the string bytes of DEBEZIUM_UNAVAILABLE_VALUE
501                // This is because when processing bytea from Debezium, we convert the base64-encoded string
502                // to `DEBEZIUM_UNAVAILABLE_VALUE` in the json.rs parser to maintain consistency
503                if let Ok(bytea_str) = std::str::from_utf8(bytea) {
504                    is_unavailable_value_str(bytea_str)
505                } else {
506                    false
507                }
508            }
509            Some(risingwave_common::types::ScalarRefImpl::Vector(vec_ref)) => {
510                // pgvector unchanged-TOAST sentinel: the JSON parser materialises an N-d
511                // vector whose every element equals `DEBEZIUM_UNAVAILABLE_VECTOR_ELEM`
512                // (currently `f32::MAX`). N matches the column's declared dimension so the
513                // value survives `check_datum_type` on its way in; the chance of a real
514                // embedding having every element simultaneously equal to that value is
515                // effectively zero.
516                let slice = vec_ref.as_slice();
517                !slice.is_empty()
518                    && slice
519                        .iter()
520                        .all(|f| f.0 == DEBEZIUM_UNAVAILABLE_VECTOR_ELEM)
521            }
522            // For list type, check if it contains exactly one element with the unavailable value.
523            // This is because when any element in an array triggers TOAST, Debezium treats the
524            // entire array as unchanged and sends a placeholder array with only one element.
525            Some(risingwave_common::types::ScalarRefImpl::List(list_ref))
526                if list_ref.len() == 1 =>
527            {
528                if let Some(Some(element)) = list_ref.get(0) {
529                    // Recursively check the array element
530                    is_debezium_unavailable_value(&Some(element))
531                } else {
532                    false
533                }
534            }
535            _ => false,
536        }
537    }
538
539    /// Fix TOAST columns by replacing unavailable values with old row values.
540    pub fn handle_toast_columns_for_postgres_cdc(
541        old_row: &OwnedRow,
542        new_row: &OwnedRow,
543        toastable_indices: &[usize],
544    ) -> OwnedRow {
545        let mut fixed_row_data = new_row.as_inner().to_vec();
546
547        for &toast_idx in toastable_indices {
548            // Check if the new value is Debezium's unavailable value placeholder
549            let is_unavailable = is_debezium_unavailable_value(&new_row.datum_at(toast_idx));
550            if is_unavailable {
551                // Replace with old row value if available
552                if let Some(old_datum_ref) = old_row.datum_at(toast_idx) {
553                    fixed_row_data[toast_idx] = Some(old_datum_ref.into_scalar_impl());
554                }
555            }
556        }
557
558        OwnedRow::new(fixed_row_data)
559    }
560}