Skip to main content

risingwave_storage/hummock/store/
table_change_log_manager.rs

1// Copyright 2026 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::future::Future;
16use std::iter;
17use std::pin::Pin;
18use std::sync::Arc;
19
20use futures::FutureExt;
21use futures::future::Shared;
22use moka::sync::Cache;
23use risingwave_common::id::TableId;
24use risingwave_hummock_sdk::change_log::TableChangeLogs;
25use risingwave_rpc_client::HummockMetaClient;
26
27use crate::hummock::{HummockError, HummockResult};
28use crate::monitor::HummockStateStoreMetrics;
29
30type InflightResult = Shared<Pin<Box<dyn Future<Output = HummockResult<TableChangeLogs>> + Send>>>;
31
32#[derive(Eq, Hash, PartialEq)]
33struct CacheKey {
34    table_id: TableId,
35    epoch_range: (u64, u64),
36    include_epoch_only: bool,
37    limit: Option<u32>,
38}
39
40/// A naive cache to reduce number of RPC sent to meta node.
41pub struct TableChangeLogManager {
42    cache: Cache<CacheKey, InflightResult>,
43    hummock_meta_client: Arc<dyn HummockMetaClient>,
44    metrics: Arc<HummockStateStoreMetrics>,
45}
46
47impl TableChangeLogManager {
48    pub fn new(
49        capacity: u64,
50        hummock_meta_client: Arc<dyn HummockMetaClient>,
51        metrics: Arc<HummockStateStoreMetrics>,
52    ) -> Self {
53        let cache = Cache::builder().max_capacity(capacity).build();
54        Self {
55            cache,
56            hummock_meta_client,
57            metrics,
58        }
59    }
60
61    async fn get_or_insert(
62        &self,
63        table_id: TableId,
64        epoch_range: (u64, u64),
65        include_epoch_only: bool,
66        limit: Option<u32>,
67        fetch: impl Future<Output = HummockResult<TableChangeLogs>> + Send + 'static,
68    ) -> HummockResult<TableChangeLogs> {
69        let entry = self
70            .cache
71            .entry(CacheKey {
72                table_id,
73                epoch_range,
74                include_epoch_only,
75                limit,
76            })
77            .or_insert_with_if(
78                || fetch.boxed().shared(),
79                |inflight| {
80                    if let Some(result) = inflight.peek() {
81                        return result.is_err();
82                    }
83                    false
84                },
85            );
86        if entry.is_fresh() {
87            self.metrics.table_change_log_cache_miss.inc();
88        } else {
89            self.metrics.table_change_log_cache_hit.inc();
90        }
91        entry.value().clone().await
92    }
93
94    /// Fetches table change logs for the given `table_id` and `epoch_range`.
95    ///
96    /// - If the end value of `epoch_range` is not `u64::MAX`, attempts to retrieve logs from the cache; if not cached, fetches via an RPC to the meta node and stores the result in the cache.
97    /// - If the end value of `epoch_range` is `u64::MAX`, always fetches table change logs directly from the meta node (bypassing the cache).
98    ///
99    /// Both the start and end values of `epoch_range` are inclusive.
100    ///
101    /// IMPORTANT: The caller must guarantee that the current max committed epoch is at least as large as the end of the provided `epoch_range`, if it's not `u64::MAX`.
102    /// Otherwise, the cache may serve outdated results: as new epochs are committed beyond the current maximum, subsequent RPC calls for the same `epoch_range`
103    /// could retrieve different or additional change logs that were not present in the previously cached result. For example, if you request logs for
104    /// `epoch_range = (1, N)` when the current max committed epoch is `M < N`, committing epochs `M+1` through `N` would make the cache inconsistent with reality;
105    /// future fetches for `(1, N)` could return new or updated information absent from the previous cache entry.
106    pub async fn fetch_table_change_logs(
107        &self,
108        table_id: TableId,
109        epoch_range: (u64, u64),
110        include_epoch_only: bool,
111        limit: Option<u32>,
112    ) -> HummockResult<TableChangeLogs> {
113        let _timer = self.metrics.table_change_log_fetch_latency.start_timer();
114        let hummock_meta_client = self.hummock_meta_client.clone();
115        let fetch = async move {
116            hummock_meta_client
117                .get_table_change_logs(
118                    include_epoch_only,
119                    Some(epoch_range.0),
120                    Some(epoch_range.1),
121                    Some(iter::once(table_id).collect()),
122                    false,
123                    limit,
124                )
125                .await
126                .map_err(HummockError::meta_error)
127        };
128        if epoch_range.1 == u64::MAX {
129            return fetch.await;
130        }
131        self.get_or_insert(table_id, epoch_range, include_epoch_only, limit, fetch)
132            .await
133    }
134}