Skip to main content

risingwave_meta/hummock/manager/
table_change_log.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::collections::{HashMap, HashSet};
16
17use anyhow::{Context, anyhow};
18use risingwave_common::catalog::TableId;
19use risingwave_common::util::epoch::Epoch;
20use risingwave_hummock_sdk::change_log::TableChangeLog;
21use risingwave_hummock_sdk::version::HummockVersion;
22use sea_orm::{
23    ColumnTrait, Condition, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, QueryFilter,
24    QueryTrait, TransactionTrait,
25};
26
27use crate::controller::streaming_job::TableChangeLogTruncateInfo;
28use crate::hummock::HummockManager;
29use crate::hummock::error::{Error, Result};
30use crate::hummock::model::ext::to_table_change_log;
31
32fn update_truncate_epoch(
33    truncate_epochs: &mut HashMap<TableId, u64>,
34    table_id: TableId,
35    truncate_epoch: u64,
36) {
37    truncate_epochs
38        .entry(table_id)
39        .and_modify(|epoch| *epoch = (*epoch).min(truncate_epoch))
40        .or_insert(truncate_epoch);
41}
42
43fn resolve_table_change_log_truncate_epochs(
44    info: &TableChangeLogTruncateInfo,
45    version: &HummockVersion,
46    current_time_epoch: Epoch,
47) -> anyhow::Result<HashMap<TableId, u64>> {
48    let mut truncate_epochs = HashMap::new();
49    let mut untruncatable_table_ids = HashSet::new();
50    for (table_id, retention_seconds) in &info.subscription_retention_seconds {
51        if version.table_committed_epoch(*table_id).is_none() {
52            // A concurrently dropped table is cleaned up by its commit-epoch transaction.
53            tracing::warn!(
54                %table_id,
55                "cannot get committed epoch for subscribed table, skip table change log truncation"
56            );
57            continue;
58        }
59        let truncate_epoch = current_time_epoch
60            .subtract_ms(retention_seconds.saturating_mul(1000))
61            .0;
62        update_truncate_epoch(&mut truncate_epochs, *table_id, truncate_epoch);
63    }
64
65    for job in &info.independent_jobs {
66        let mut all_snapshot_epochs_none = true;
67        for (upstream_table_id, snapshot_epoch) in &job.upstream_table_snapshot_epochs {
68            match snapshot_epoch {
69                Some(_) => all_snapshot_epochs_none = false,
70                None => {
71                    // The independent job has not fixed a safe snapshot epoch yet. This vetoes
72                    // truncation even when another consumer provides a concrete cutoff.
73                    untruncatable_table_ids.insert(*upstream_table_id);
74                }
75            }
76        }
77        if all_snapshot_epochs_none {
78            continue;
79        }
80
81        let mut state_table_ids = job.state_table_ids.iter();
82        let first_table_id = state_table_ids
83            .next()
84            .ok_or_else(|| anyhow!("independent job {} has no state table", job.job_id))?;
85        let committed_epoch = version
86            .table_committed_epoch(*first_table_id)
87            .ok_or_else(|| {
88                anyhow!(
89                    "cannot get committed epoch of state table {} in independent job {}",
90                    first_table_id,
91                    job.job_id
92                )
93            })?;
94        for table_id in state_table_ids {
95            let table_committed_epoch =
96                version.table_committed_epoch(*table_id).ok_or_else(|| {
97                    anyhow!(
98                        "cannot get committed epoch of state table {} in independent job {}",
99                        table_id,
100                        job.job_id
101                    )
102                })?;
103            if table_committed_epoch != committed_epoch {
104                return Err(anyhow!(
105                    "state tables {} and {} in independent job {} have different committed epochs {} and {}",
106                    first_table_id,
107                    table_id,
108                    job.job_id,
109                    committed_epoch,
110                    table_committed_epoch
111                ));
112            }
113        }
114
115        for (upstream_table_id, snapshot_epoch) in &job.upstream_table_snapshot_epochs {
116            if let Some(snapshot_epoch) = snapshot_epoch {
117                let pinned_epoch = committed_epoch.max(*snapshot_epoch);
118                update_truncate_epoch(&mut truncate_epochs, *upstream_table_id, pinned_epoch);
119            }
120        }
121    }
122    truncate_epochs.retain(|table_id, _| !untruncatable_table_ids.contains(table_id));
123    Ok(truncate_epochs)
124}
125
126impl HummockManager {
127    pub async fn truncate_table_change_log(&self, info: TableChangeLogTruncateInfo) -> Result<()> {
128        let _timer = self.metrics.table_change_log_truncate_latency.start_timer();
129        let mut versioning = self
130            .versioning
131            .write_with_process_name("truncate_table_change_log")
132            .await;
133        let current_time_epoch = Epoch::now();
134        let truncate_epochs = resolve_table_change_log_truncate_epochs(
135            &info,
136            versioning.current_version.as_ref(),
137            current_time_epoch,
138        )
139        .map_err(Error::Internal)?;
140        let truncate_epochs: Vec<_> = truncate_epochs
141            .into_iter()
142            .filter(|(table_id, _)| versioning.table_change_log.contains_key(table_id))
143            .collect();
144        if truncate_epochs.is_empty() {
145            return Ok(());
146        }
147
148        let sql_store = self.env.meta_store_ref();
149        let txn = sql_store.conn.begin().await?;
150        let batch_size = self.env.opts.table_change_log_delete_batch_size as usize;
151        let mut rows_affected = 0;
152        let mut may_delete_object_ids = HashSet::new();
153        for batch in truncate_epochs.chunks(batch_size) {
154            let mut condition = Condition::any();
155            for (table_id, truncate_epoch) in batch {
156                let truncate_epoch = risingwave_meta_model::Epoch::try_from(*truncate_epoch)
157                    .context("table change log truncate epoch exceeds meta store range")
158                    .map_err(Error::Internal)?;
159                condition = condition.add(
160                    Condition::all()
161                        .add(
162                            risingwave_meta_model::hummock_table_change_log::Column::TableId
163                                .eq(*table_id),
164                        )
165                        .add(
166                            risingwave_meta_model::hummock_table_change_log::Column::CheckpointEpoch
167                                .lt(truncate_epoch),
168                        ),
169                );
170            }
171            let (change_logs_to_delete, deleted_count) = match txn.get_database_backend() {
172                DbBackend::Postgres => {
173                    let mut delete =
174                        risingwave_meta_model::hummock_table_change_log::Entity::delete_many()
175                            .filter(condition)
176                            .into_query();
177                    delete.returning_all();
178                    let statement = DbBackend::Postgres.build(&delete);
179                    let change_logs_to_delete = txn
180                        .query_all(statement)
181                        .await?
182                        .iter()
183                        .map(|row| {
184                            risingwave_meta_model::hummock_table_change_log::Model::from_query_result(
185                                row, "",
186                            )
187                        })
188                        .collect::<std::result::Result<Vec<_>, _>>()?;
189                    let deleted_count = change_logs_to_delete.len() as u64;
190                    (change_logs_to_delete, deleted_count)
191                }
192                DbBackend::MySql | DbBackend::Sqlite => {
193                    // MySQL does not support DELETE RETURNING, and SQLite returning support is not
194                    // enabled in SeaORM, so select the rows in the same transaction before deleting.
195                    let change_logs_to_delete =
196                        risingwave_meta_model::hummock_table_change_log::Entity::find()
197                            .filter(condition.clone())
198                            .all(&txn)
199                            .await?;
200                    let deleted_count =
201                        risingwave_meta_model::hummock_table_change_log::Entity::delete_many()
202                            .filter(condition)
203                            .exec(&txn)
204                            .await?
205                            .rows_affected;
206                    (change_logs_to_delete, deleted_count)
207                }
208            };
209            for change_log_to_delete in change_logs_to_delete {
210                let deleted_change_log =
211                    TableChangeLog::new([to_table_change_log(change_log_to_delete)]);
212                may_delete_object_ids.extend(deleted_change_log.get_object_ids());
213            }
214            rows_affected += deleted_count;
215        }
216        txn.commit().await?;
217
218        for (table_id, truncate_epoch) in truncate_epochs {
219            if let Some(change_log) = versioning.table_change_log.get_mut(&table_id) {
220                change_log.truncate(truncate_epoch);
221            }
222        }
223        drop(versioning);
224        let may_delete_object_count = may_delete_object_ids.len();
225        self.gc_manager
226            .add_may_delete_object_ids(may_delete_object_ids.into_iter());
227        tracing::info!(
228            rows_affected,
229            may_delete_object_count,
230            "truncated table change logs"
231        );
232        Ok(())
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use std::collections::{HashMap, HashSet};
239
240    use risingwave_common::id::JobId;
241    use risingwave_pb::hummock::StateTableInfoDelta;
242
243    use super::*;
244    use crate::controller::streaming_job::IndependentJobChangeLogInfo;
245
246    fn version_with_committed_epochs(
247        committed_epochs: impl IntoIterator<Item = (TableId, u64)>,
248    ) -> HummockVersion {
249        let mut version = HummockVersion::default();
250        let mut delta = version.version_delta_after();
251        for (table_id, committed_epoch) in committed_epochs {
252            delta.state_table_info_delta.insert(
253                table_id,
254                StateTableInfoDelta {
255                    committed_epoch,
256                    compaction_group_id: 1.into(),
257                },
258            );
259        }
260        version.apply_version_delta(&delta);
261        version
262    }
263
264    #[test]
265    fn test_resolve_table_change_log_truncate_epochs() {
266        let upstream_table_id = TableId::new(1);
267        let job_state_table_id = TableId::new(2);
268        let current_time_epoch = Epoch::from_physical_time(100_000);
269        let subscription_epoch = Epoch::from_physical_time(70_000).0;
270        let job_committed_epoch = Epoch::from_physical_time(80_000).0;
271        let snapshot_epoch = Epoch::from_physical_time(85_000).0;
272        let version = version_with_committed_epochs([
273            (upstream_table_id, subscription_epoch),
274            (job_state_table_id, job_committed_epoch),
275        ]);
276        let info = TableChangeLogTruncateInfo {
277            subscription_retention_seconds: HashMap::from([(upstream_table_id, 10)]),
278            independent_jobs: vec![IndependentJobChangeLogInfo {
279                job_id: JobId::new(3),
280                state_table_ids: HashSet::from([job_state_table_id]),
281                upstream_table_snapshot_epochs: HashMap::from([(
282                    upstream_table_id,
283                    Some(snapshot_epoch),
284                )]),
285            }],
286        };
287
288        let truncate_epochs =
289            resolve_table_change_log_truncate_epochs(&info, &version, current_time_epoch).unwrap();
290        assert_eq!(truncate_epochs[&upstream_table_id], snapshot_epoch);
291    }
292
293    #[test]
294    fn test_missing_snapshot_epoch_prevents_truncation() {
295        let upstream_table_id = TableId::new(1);
296        let job_state_table_id = TableId::new(2);
297        let upstream_committed_epoch = Epoch::from_physical_time(100_000).0;
298        let job_committed_epoch = Epoch::from_physical_time(80_000).0;
299        let version = version_with_committed_epochs([
300            (upstream_table_id, upstream_committed_epoch),
301            (job_state_table_id, job_committed_epoch),
302        ]);
303        let info = TableChangeLogTruncateInfo {
304            subscription_retention_seconds: HashMap::from([(upstream_table_id, 10)]),
305            independent_jobs: vec![IndependentJobChangeLogInfo {
306                job_id: JobId::new(3),
307                state_table_ids: HashSet::from([job_state_table_id]),
308                upstream_table_snapshot_epochs: HashMap::from([(upstream_table_id, None)]),
309            }],
310        };
311
312        let truncate_epochs = resolve_table_change_log_truncate_epochs(
313            &info,
314            &version,
315            Epoch::from_physical_time(100_000),
316        )
317        .unwrap();
318        assert!(!truncate_epochs.contains_key(&upstream_table_id));
319    }
320
321    #[test]
322    fn test_inconsistent_job_committed_epoch_fails() {
323        let state_table_id_1 = TableId::new(1);
324        let state_table_id_2 = TableId::new(2);
325        let version = version_with_committed_epochs([
326            (state_table_id_1, Epoch::from_physical_time(1).0),
327            (state_table_id_2, Epoch::from_physical_time(2).0),
328        ]);
329        let info = TableChangeLogTruncateInfo {
330            subscription_retention_seconds: HashMap::new(),
331            independent_jobs: vec![IndependentJobChangeLogInfo {
332                job_id: JobId::new(3),
333                state_table_ids: HashSet::from([state_table_id_1, state_table_id_2]),
334                upstream_table_snapshot_epochs: HashMap::from([(
335                    TableId::new(4),
336                    Some(Epoch::from_physical_time(1).0),
337                )]),
338            }],
339        };
340
341        assert!(
342            resolve_table_change_log_truncate_epochs(
343                &info,
344                &version,
345                Epoch::from_physical_time(100_000),
346            )
347            .is_err()
348        );
349    }
350}