risingwave_meta/hummock/manager/
table_change_log.rs1use 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 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 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 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}