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};
16use std::sync::Arc;
17use std::time::Instant;
18
19use anyhow::{Context, anyhow};
20use risingwave_common::catalog::TableId;
21use risingwave_common::util::epoch::Epoch;
22use risingwave_hummock_sdk::change_log::TableChangeLog;
23use risingwave_hummock_sdk::version::HummockVersion;
24use sea_orm::{
25    ColumnTrait, Condition, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, QueryFilter,
26    QueryTrait, TransactionTrait,
27};
28use tracing::warn;
29
30use crate::controller::streaming_job::{
31    ProgressTableChangeLogSubscriber, TableChangeLogTruncateInfo,
32};
33use crate::hummock::HummockManager;
34use crate::hummock::error::{Error, Result};
35use crate::hummock::manager::gc::change_log_retention::RetainedChangeLogBatch;
36use crate::hummock::model::ext::to_table_change_log;
37
38#[derive(Clone, Debug)]
39pub struct CommittedTableChangeLogProgress {
40    pub upstream_table_id: TableId,
41    pub progress_table_id: TableId,
42    pub truncate_epoch: u64,
43}
44
45fn update_truncate_epoch(
46    truncate_epochs: &mut HashMap<TableId, u64>,
47    table_id: TableId,
48    truncate_epoch: u64,
49) {
50    truncate_epochs
51        .entry(table_id)
52        .and_modify(|epoch| *epoch = (*epoch).min(truncate_epoch))
53        .or_insert(truncate_epoch);
54}
55
56fn resolve_progress_table_truncate_epochs(
57    subscribers: &[ProgressTableChangeLogSubscriber],
58    progress: &HashMap<TableId, (TableId, u64)>,
59) -> (HashMap<TableId, u64>, HashSet<TableId>) {
60    let mut truncate_epochs = HashMap::new();
61    let mut untruncatable_table_ids = HashSet::new();
62    for subscriber in subscribers {
63        match progress.get(&subscriber.progress_table_id) {
64            Some((upstream_table_id, truncate_epoch))
65                if *upstream_table_id == subscriber.upstream_table_id =>
66            {
67                update_truncate_epoch(&mut truncate_epochs, *upstream_table_id, *truncate_epoch);
68            }
69            Some((upstream_table_id, _)) => {
70                debug_assert_eq!(
71                    *upstream_table_id, subscriber.upstream_table_id,
72                    "progress table {} changed upstream table",
73                    subscriber.progress_table_id
74                );
75                warn!(
76                    progress_table_id = %subscriber.progress_table_id,
77                    expected_upstream_table_id = %subscriber.upstream_table_id,
78                    actual_upstream_table_id = %upstream_table_id,
79                    "progress table changed upstream table; veto table change log truncation"
80                );
81                untruncatable_table_ids.insert(subscriber.upstream_table_id);
82            }
83            None => {
84                untruncatable_table_ids.insert(subscriber.upstream_table_id);
85            }
86        }
87    }
88    (truncate_epochs, untruncatable_table_ids)
89}
90
91fn resolve_table_change_log_truncate_epochs(
92    info: &TableChangeLogTruncateInfo,
93    version: &HummockVersion,
94    current_time_epoch: Epoch,
95) -> anyhow::Result<HashMap<TableId, u64>> {
96    let mut truncate_epochs = HashMap::new();
97    let mut untruncatable_table_ids = HashSet::new();
98    for (table_id, retention_seconds) in &info.subscription_retention_seconds {
99        if version.table_committed_epoch(*table_id).is_none() {
100            // A concurrently dropped table is cleaned up by its commit-epoch transaction.
101            tracing::warn!(
102                %table_id,
103                "cannot get committed epoch for subscribed table, skip table change log truncation"
104            );
105            continue;
106        }
107        let truncate_epoch = current_time_epoch
108            .subtract_ms(retention_seconds.saturating_mul(1000))
109            .0;
110        update_truncate_epoch(&mut truncate_epochs, *table_id, truncate_epoch);
111    }
112
113    for job in &info.committed_epoch_truncate_jobs {
114        let mut all_snapshot_epochs_none = true;
115        for (upstream_table_id, snapshot_epoch) in &job.upstream_table_snapshot_epochs {
116            match snapshot_epoch {
117                Some(_) => all_snapshot_epochs_none = false,
118                None => {
119                    // The job has not fixed a safe snapshot epoch yet. This vetoes truncation
120                    // even when another subscriber provides a concrete cutoff.
121                    untruncatable_table_ids.insert(*upstream_table_id);
122                }
123            }
124        }
125        if all_snapshot_epochs_none {
126            continue;
127        }
128
129        let mut state_table_ids = job.state_table_ids.iter();
130        let first_table_id = state_table_ids.next().ok_or_else(|| {
131            anyhow!(
132                "committed epoch truncate job {} has no state table",
133                job.job_id
134            )
135        })?;
136        let committed_epoch = version
137            .table_committed_epoch(*first_table_id)
138            .ok_or_else(|| {
139                anyhow!(
140                    "cannot get committed epoch of state table {} in committed epoch truncate job {}",
141                    first_table_id,
142                    job.job_id
143                )
144            })?;
145        for table_id in state_table_ids {
146            let table_committed_epoch =
147                version.table_committed_epoch(*table_id).ok_or_else(|| {
148                    anyhow!(
149                        "cannot get committed epoch of state table {} in committed epoch truncate job {}",
150                        table_id,
151                        job.job_id
152                    )
153                })?;
154            if table_committed_epoch != committed_epoch {
155                return Err(anyhow!(
156                    "state tables {} and {} in committed epoch truncate job {} have different committed epochs {} and {}",
157                    first_table_id,
158                    table_id,
159                    job.job_id,
160                    committed_epoch,
161                    table_committed_epoch
162                ));
163            }
164        }
165
166        for (upstream_table_id, snapshot_epoch) in &job.upstream_table_snapshot_epochs {
167            if let Some(snapshot_epoch) = snapshot_epoch {
168                let pinned_epoch = committed_epoch.max(*snapshot_epoch);
169                update_truncate_epoch(&mut truncate_epochs, *upstream_table_id, pinned_epoch);
170            }
171        }
172    }
173    truncate_epochs.retain(|table_id, _| !untruncatable_table_ids.contains(table_id));
174    Ok(truncate_epochs)
175}
176
177impl HummockManager {
178    pub(super) fn update_table_change_log_progress(
179        &self,
180        updates: impl IntoIterator<Item = CommittedTableChangeLogProgress>,
181    ) {
182        let mut progress = self.table_change_log_progress.write();
183        for update in updates {
184            let key = update.progress_table_id;
185            progress
186                .entry(key)
187                .and_modify(|(upstream_table_id, truncate_epoch)| {
188                    assert_eq!(*upstream_table_id, update.upstream_table_id);
189                    if update.truncate_epoch < *truncate_epoch {
190                        debug_assert!(
191                            update.truncate_epoch >= *truncate_epoch,
192                            "table change log progress regressed from {} to {} for progress table {}",
193                            *truncate_epoch,
194                            update.truncate_epoch,
195                            update.progress_table_id,
196                        );
197                        warn!(
198                            progress_table_id = %update.progress_table_id,
199                            previous_epoch = *truncate_epoch,
200                            reported_epoch = update.truncate_epoch,
201                            "table change log progress regressed"
202                        );
203                    } else {
204                        *truncate_epoch = update.truncate_epoch;
205                    }
206                })
207                .or_insert((update.upstream_table_id, update.truncate_epoch));
208        }
209    }
210
211    pub async fn truncate_table_change_log(&self, info: TableChangeLogTruncateInfo) -> Result<()> {
212        let _timer = self.metrics.table_change_log_truncate_latency.start_timer();
213        let resolution_started_at = Instant::now();
214        let (progress_table_truncate_epochs, progress_table_untruncatable_table_ids) = {
215            let progress = self.table_change_log_progress.read();
216            resolve_progress_table_truncate_epochs(&info.progress_table_subscribers, &progress)
217        };
218        let mut versioning = self
219            .versioning
220            .write_with_process_name("truncate_table_change_log")
221            .await;
222        let current_time_epoch = Epoch::now();
223        let truncate_epochs = resolve_table_change_log_truncate_epochs(
224            &info,
225            versioning.current_version.as_ref(),
226            current_time_epoch,
227        )
228        .map_err(Error::Internal)?;
229        let mut truncate_epochs = truncate_epochs;
230        for (table_id, truncate_epoch) in progress_table_truncate_epochs {
231            update_truncate_epoch(&mut truncate_epochs, table_id, truncate_epoch);
232        }
233        truncate_epochs
234            .retain(|table_id, _| !progress_table_untruncatable_table_ids.contains(table_id));
235        let truncate_epochs: Vec<_> = truncate_epochs
236            .into_iter()
237            .filter(|(table_id, _)| versioning.table_change_log.contains_key(table_id))
238            .collect();
239        let resolution_time = resolution_started_at.elapsed();
240        let table_count = truncate_epochs.len();
241        if truncate_epochs.is_empty() {
242            tracing::info!(
243                resolution_time = ?resolution_time,
244                truncation_time = ?std::time::Duration::ZERO,
245                table_count,
246                rows_affected = 0,
247                may_delete_object_count = 0,
248                "table change log truncation finished"
249            );
250            return Ok(());
251        }
252
253        let truncation_started_at = Instant::now();
254        let sql_store = self.env.meta_store_ref();
255        let txn = sql_store.conn.begin().await?;
256        let batch_size = self.env.opts.table_change_log_delete_batch_size as usize;
257        let mut rows_affected = 0;
258        let mut may_delete_object_ids = HashSet::new();
259        for batch in truncate_epochs.chunks(batch_size) {
260            let mut condition = Condition::any();
261            for (table_id, truncate_epoch) in batch {
262                let truncate_epoch = risingwave_meta_model::Epoch::try_from(*truncate_epoch)
263                    .context("table change log truncate epoch exceeds meta store range")
264                    .map_err(Error::Internal)?;
265                condition = condition.add(
266                    Condition::all()
267                        .add(
268                            risingwave_meta_model::hummock_table_change_log::Column::TableId
269                                .eq(*table_id),
270                        )
271                        .add(
272                            risingwave_meta_model::hummock_table_change_log::Column::CheckpointEpoch
273                                .lt(truncate_epoch),
274                        ),
275                );
276            }
277            let (change_logs_to_delete, deleted_count) = match txn.get_database_backend() {
278                DbBackend::Postgres => {
279                    let mut delete =
280                        risingwave_meta_model::hummock_table_change_log::Entity::delete_many()
281                            .filter(condition)
282                            .into_query();
283                    delete.returning_all();
284                    let statement = DbBackend::Postgres.build(&delete);
285                    let change_logs_to_delete = txn
286                        .query_all(statement)
287                        .await?
288                        .iter()
289                        .map(|row| {
290                            risingwave_meta_model::hummock_table_change_log::Model::from_query_result(
291                                row, "",
292                            )
293                        })
294                        .collect::<std::result::Result<Vec<_>, _>>()?;
295                    let deleted_count = change_logs_to_delete.len() as u64;
296                    (change_logs_to_delete, deleted_count)
297                }
298                DbBackend::MySql | DbBackend::Sqlite => {
299                    // MySQL does not support DELETE RETURNING, and SQLite returning support is not
300                    // enabled in SeaORM, so select the rows in the same transaction before deleting.
301                    let change_logs_to_delete =
302                        risingwave_meta_model::hummock_table_change_log::Entity::find()
303                            .filter(condition.clone())
304                            .all(&txn)
305                            .await?;
306                    let deleted_count =
307                        risingwave_meta_model::hummock_table_change_log::Entity::delete_many()
308                            .filter(condition)
309                            .exec(&txn)
310                            .await?
311                            .rows_affected;
312                    (change_logs_to_delete, deleted_count)
313                }
314            };
315            for change_log_to_delete in change_logs_to_delete {
316                let deleted_change_log =
317                    TableChangeLog::new([to_table_change_log(change_log_to_delete)]);
318                may_delete_object_ids.extend(deleted_change_log.get_object_ids());
319            }
320            rows_affected += deleted_count;
321        }
322        txn.commit().await?;
323
324        for (table_id, truncate_epoch) in truncate_epochs {
325            if let Some(change_log) = versioning.table_change_log.get_mut(&table_id) {
326                change_log.truncate(truncate_epoch);
327            }
328        }
329        // Transfer the already collected IDs without a second per-object pass under
330        // the versioning lock. Keep a shared handle for the minor-GC queue below.
331        let may_delete_object_ids = Arc::new(may_delete_object_ids);
332        self.gc_manager
333            .change_log_retention
334            .retain_batch(RetainedChangeLogBatch::ObjectIds(
335                may_delete_object_ids.clone(),
336            ));
337        drop(versioning);
338        let may_delete_object_count = may_delete_object_ids.len();
339        self.gc_manager
340            .add_may_delete_object_ids(may_delete_object_ids.iter().copied());
341        let truncation_time = truncation_started_at.elapsed();
342        tracing::info!(
343            resolution_time = ?resolution_time,
344            truncation_time = ?truncation_time,
345            table_count,
346            rows_affected,
347            may_delete_object_count,
348            "truncated table change logs"
349        );
350        Ok(())
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use std::collections::{HashMap, HashSet};
357
358    use risingwave_common::id::JobId;
359    use risingwave_pb::hummock::StateTableInfoDelta;
360
361    use super::*;
362    use crate::controller::streaming_job::{
363        CommittedEpochTruncateJob, ProgressTableChangeLogSubscriber,
364    };
365
366    #[test]
367    fn test_resolve_progress_table_truncate_epochs() {
368        let upstream_table_id = TableId::new(1);
369        let subscribers = vec![
370            ProgressTableChangeLogSubscriber {
371                subscriber_job_id: JobId::new(2),
372                upstream_table_id,
373                progress_table_id: TableId::new(3),
374            },
375            ProgressTableChangeLogSubscriber {
376                subscriber_job_id: JobId::new(4),
377                upstream_table_id,
378                progress_table_id: TableId::new(5),
379            },
380        ];
381        let progress = HashMap::from([
382            (TableId::new(3), (upstream_table_id, 100)),
383            (TableId::new(5), (upstream_table_id, 80)),
384        ]);
385
386        let (truncate_epochs, untruncatable) =
387            resolve_progress_table_truncate_epochs(&subscribers, &progress);
388        assert_eq!(truncate_epochs, HashMap::from([(upstream_table_id, 80)]));
389        assert!(untruncatable.is_empty());
390
391        let partial_progress = HashMap::from([(TableId::new(3), (upstream_table_id, 100))]);
392        let (truncate_epochs, untruncatable) =
393            resolve_progress_table_truncate_epochs(&subscribers, &partial_progress);
394        assert_eq!(truncate_epochs, HashMap::from([(upstream_table_id, 100)]));
395        assert_eq!(untruncatable, HashSet::from([upstream_table_id]));
396    }
397
398    fn version_with_committed_epochs(
399        committed_epochs: impl IntoIterator<Item = (TableId, u64)>,
400    ) -> HummockVersion {
401        let mut version = HummockVersion::default();
402        let mut delta = version.version_delta_after();
403        for (table_id, committed_epoch) in committed_epochs {
404            delta.state_table_info_delta.insert(
405                table_id,
406                StateTableInfoDelta {
407                    committed_epoch,
408                    compaction_group_id: 1.into(),
409                },
410            );
411        }
412        version.apply_version_delta(&delta);
413        version
414    }
415
416    #[test]
417    fn test_resolve_table_change_log_truncate_epochs() {
418        let upstream_table_id = TableId::new(1);
419        let job_state_table_id = TableId::new(2);
420        let current_time_epoch = Epoch::from_physical_time(100_000);
421        let subscription_epoch = Epoch::from_physical_time(70_000).0;
422        let job_committed_epoch = Epoch::from_physical_time(80_000).0;
423        let snapshot_epoch = Epoch::from_physical_time(85_000).0;
424        let version = version_with_committed_epochs([
425            (upstream_table_id, subscription_epoch),
426            (job_state_table_id, job_committed_epoch),
427        ]);
428        let info = TableChangeLogTruncateInfo {
429            subscription_retention_seconds: HashMap::from([(upstream_table_id, 10)]),
430            progress_table_subscribers: vec![],
431            committed_epoch_truncate_jobs: vec![CommittedEpochTruncateJob {
432                job_id: JobId::new(3),
433                state_table_ids: HashSet::from([job_state_table_id]),
434                upstream_table_snapshot_epochs: HashMap::from([(
435                    upstream_table_id,
436                    Some(snapshot_epoch),
437                )]),
438            }],
439        };
440
441        let truncate_epochs =
442            resolve_table_change_log_truncate_epochs(&info, &version, current_time_epoch).unwrap();
443        assert_eq!(truncate_epochs[&upstream_table_id], snapshot_epoch);
444    }
445
446    #[test]
447    fn test_missing_snapshot_epoch_prevents_truncation() {
448        let upstream_table_id = TableId::new(1);
449        let job_state_table_id = TableId::new(2);
450        let upstream_committed_epoch = Epoch::from_physical_time(100_000).0;
451        let job_committed_epoch = Epoch::from_physical_time(80_000).0;
452        let version = version_with_committed_epochs([
453            (upstream_table_id, upstream_committed_epoch),
454            (job_state_table_id, job_committed_epoch),
455        ]);
456        let info = TableChangeLogTruncateInfo {
457            subscription_retention_seconds: HashMap::from([(upstream_table_id, 10)]),
458            progress_table_subscribers: vec![],
459            committed_epoch_truncate_jobs: vec![CommittedEpochTruncateJob {
460                job_id: JobId::new(3),
461                state_table_ids: HashSet::from([job_state_table_id]),
462                upstream_table_snapshot_epochs: HashMap::from([(upstream_table_id, None)]),
463            }],
464        };
465
466        let truncate_epochs = resolve_table_change_log_truncate_epochs(
467            &info,
468            &version,
469            Epoch::from_physical_time(100_000),
470        )
471        .unwrap();
472        assert!(!truncate_epochs.contains_key(&upstream_table_id));
473    }
474
475    #[test]
476    fn test_inconsistent_job_committed_epoch_fails() {
477        let state_table_id_1 = TableId::new(1);
478        let state_table_id_2 = TableId::new(2);
479        let version = version_with_committed_epochs([
480            (state_table_id_1, Epoch::from_physical_time(1).0),
481            (state_table_id_2, Epoch::from_physical_time(2).0),
482        ]);
483        let info = TableChangeLogTruncateInfo {
484            subscription_retention_seconds: HashMap::new(),
485            progress_table_subscribers: vec![],
486            committed_epoch_truncate_jobs: vec![CommittedEpochTruncateJob {
487                job_id: JobId::new(3),
488                state_table_ids: HashSet::from([state_table_id_1, state_table_id_2]),
489                upstream_table_snapshot_epochs: HashMap::from([(
490                    TableId::new(4),
491                    Some(Epoch::from_physical_time(1).0),
492                )]),
493            }],
494        };
495
496        assert!(
497            resolve_table_change_log_truncate_epochs(
498                &info,
499                &version,
500                Epoch::from_physical_time(100_000),
501            )
502            .is_err()
503        );
504    }
505}