Skip to main content

risingwave_meta/manager/iceberg_pk_index_sink/
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::collections::{HashMap, HashSet};
16use std::sync::Arc;
17use std::sync::atomic::{AtomicBool, Ordering};
18
19use anyhow::anyhow;
20use arc_swap::ArcSwap;
21use parking_lot::RwLock;
22use risingwave_common::id::{JobId, PartialGraphId};
23use risingwave_connector::connector_common::{
24    IcebergCommittedSnapshot, IcebergSinkCompactionUpdate,
25};
26use risingwave_connector::sink::catalog::SinkId;
27use risingwave_connector::sink::iceberg::IcebergConfig;
28use tokio::sync::Mutex;
29use tokio::sync::mpsc::UnboundedSender;
30use tracing::warn;
31
32use super::committed_epoch::PartialGraphCommittedEpochs;
33use super::coordinator::IcebergPkIndexSinkCoordinator;
34use super::{IcebergPkIndexPreCommitMetadata, build_iceberg_config, is_iceberg_pk_index_sink};
35use crate::controller::catalog::CatalogControllerRef;
36use crate::manager::iceberg_compaction::IcebergCompactionManagerRef;
37
38type CoordinatorRef = Arc<Mutex<IcebergPkIndexSinkCoordinator>>;
39
40/// Scheduling holds this registration, not an independent copy of sink liveness.
41pub(crate) struct IcebergPkIndexSinkRegistration {
42    ready: AtomicBool,
43    config: ArcSwap<IcebergConfig>,
44}
45
46impl std::fmt::Debug for IcebergPkIndexSinkRegistration {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.debug_struct("IcebergPkIndexSinkRegistration")
49            .field("ready", &self.is_ready())
50            .finish_non_exhaustive()
51    }
52}
53
54impl IcebergPkIndexSinkRegistration {
55    pub(crate) fn is_ready(&self) -> bool {
56        self.ready.load(Ordering::Acquire)
57    }
58
59    pub(crate) fn config(&self) -> Arc<IcebergConfig> {
60        self.config.load_full()
61    }
62
63    #[cfg(test)]
64    pub(crate) fn for_test(config: IcebergConfig) -> Arc<Self> {
65        Arc::new(Self {
66            ready: AtomicBool::new(true),
67            config: ArcSwap::from_pointee(config),
68        })
69    }
70
71    #[cfg(test)]
72    pub(crate) fn retire_for_test(&self) {
73        self.ready.store(false, Ordering::Release);
74    }
75}
76
77struct RegisteredSink {
78    partial_graph_id: PartialGraphId,
79    registration: Arc<IcebergPkIndexSinkRegistration>,
80    coordinator: Option<CoordinatorRef>,
81}
82
83/// Manager for the Iceberg pk-index sink path, cheap to clone.
84#[derive(Clone)]
85pub struct IcebergPkIndexSinkManager {
86    inner: Arc<ManagerInner>,
87}
88
89struct ManagerInner {
90    catalog_controller: CatalogControllerRef,
91    compaction_manager: IcebergCompactionManagerRef,
92    /// A registration exists before async initialization starts. Completion can only fill that
93    /// same live entry. Registry -> compaction is the control-plane lock order; neither lock is
94    /// held across coordinator I/O.
95    coordinators: RwLock<HashMap<SinkId, RegisteredSink>>,
96    /// Per-partial-graph committed-epoch cursor. A cursor entry exists exactly while a partial graph has
97    /// a registered pk-index sink: created in `register_sink` and removed on retirement or recovery,
98    /// all under the `coordinators` write lock. Advanced on
99    /// every checkpoint completion via `advance_committed_epochs` (a no-op for partial graphs with no
100    /// registered sink).
101    committed_epochs: PartialGraphCommittedEpochs,
102    compaction_stat_tx: UnboundedSender<IcebergSinkCompactionUpdate>,
103}
104
105impl IcebergPkIndexSinkManager {
106    pub fn new(
107        catalog_controller: CatalogControllerRef,
108        compaction_manager: IcebergCompactionManagerRef,
109        compaction_stat_tx: UnboundedSender<IcebergSinkCompactionUpdate>,
110    ) -> Self {
111        IcebergPkIndexSinkManager {
112            inner: Arc::new(ManagerInner {
113                catalog_controller,
114                compaction_manager,
115                coordinators: RwLock::new(HashMap::new()),
116                committed_epochs: PartialGraphCommittedEpochs::default(),
117                compaction_stat_tx,
118            }),
119        }
120    }
121
122    /// Reserve a live catalog-backed registration before loading/replaying the coordinator.
123    /// Retirement, replacement, failure or cancellation prevents late initialization from
124    /// installing a new coordinator. Graph readiness is published separately by Barrier.
125    pub async fn register_sink(
126        &self,
127        sink_id: SinkId,
128        partial_graph_id: PartialGraphId,
129    ) -> anyhow::Result<()> {
130        self.register_sink_with(
131            sink_id,
132            partial_graph_id,
133            IcebergPkIndexSinkCoordinator::init,
134        )
135        .await
136    }
137
138    async fn register_sink_with<F, Fut>(
139        &self,
140        sink_id: SinkId,
141        partial_graph_id: PartialGraphId,
142        initialize: F,
143    ) -> anyhow::Result<()>
144    where
145        F: FnOnce(SinkId, IcebergConfig, sea_orm::DatabaseConnection) -> Fut + Send,
146        Fut: Future<Output = anyhow::Result<IcebergPkIndexSinkCoordinator>> + Send,
147    {
148        let (registration, config, db) = {
149            // Deletion cannot pass the reservation. Do not reacquire the catalog read lock via
150            // CatalogController's wrapper while holding this guard.
151            let catalog = self.inner.catalog_controller.get_inner_read_guard().await;
152            let sink = catalog
153                .get_sink_by_id(sink_id)
154                .await?
155                .ok_or_else(|| anyhow!("iceberg pk-index sink {} no longer exists", sink_id))?;
156            anyhow::ensure!(
157                is_iceberg_pk_index_sink(&sink.properties),
158                "sink {} is not an iceberg pk-index sink",
159                sink_id
160            );
161            let config = build_iceberg_config(&sink)?;
162            let registration = Arc::new(IcebergPkIndexSinkRegistration {
163                ready: AtomicBool::new(false),
164                config: ArcSwap::from_pointee(config.clone()),
165            });
166            let mut coordinators = self.inner.coordinators.write();
167            Self::remove_sink(&mut coordinators, sink_id, &self.inner.committed_epochs);
168            coordinators.insert(
169                sink_id,
170                RegisteredSink {
171                    partial_graph_id,
172                    registration: registration.clone(),
173                    coordinator: None,
174                },
175            );
176            self.inner
177                .compaction_manager
178                .on_recovery_start(Some(&[sink_id]));
179            self.inner.committed_epochs.ensure(partial_graph_id);
180            (registration, config, catalog.db.clone())
181        };
182        let cleanup = scopeguard::guard(registration, |registration| {
183            let mut coordinators = self.inner.coordinators.write();
184            if coordinators
185                .get(&sink_id)
186                .is_some_and(|sink| Arc::ptr_eq(&sink.registration, &registration))
187            {
188                Self::remove_sink(&mut coordinators, sink_id, &self.inner.committed_epochs);
189            }
190        });
191        let coordinator = initialize(sink_id, config, db).await?;
192        {
193            let mut coordinators = self.inner.coordinators.write();
194            let sink = coordinators
195                .get_mut(&sink_id)
196                .filter(|sink| Arc::ptr_eq(&sink.registration, &cleanup))
197                .ok_or_else(|| {
198                    anyhow!("iceberg pk-index sink {} registration was retired", sink_id)
199                })?;
200            sink.coordinator = Some(Arc::new(Mutex::new(coordinator)));
201        }
202        scopeguard::ScopeGuard::into_inner(cleanup);
203        Ok(())
204    }
205
206    /// Barrier calls this only after the jobs' initial graphs and command queues are ready.
207    /// Read the settled head once, then validate and publish under the registry lock.
208    pub(crate) async fn on_jobs_ready(&self, job_ids: &HashSet<JobId>) {
209        let sinks = {
210            let coordinators = self.inner.coordinators.read();
211            coordinators
212                .iter()
213                .filter(|(sink_id, sink)| {
214                    job_ids.contains(&sink_id.as_job_id()) && !sink.registration.is_ready()
215                })
216                .filter_map(|(&sink_id, sink)| {
217                    sink.coordinator.as_ref().map(|coordinator| {
218                        (sink_id, sink.registration.clone(), coordinator.clone())
219                    })
220                })
221                .collect::<Vec<_>>()
222        };
223        for (sink_id, registration, coordinator) in sinks {
224            // Do not hold the registry lock while waiting. Retain the coordinator guard through
225            // publication so commits cannot fall between observing the head and enabling updates.
226            let coordinator = coordinator.lock().await;
227            let coordinators = self.inner.coordinators.write();
228            if !coordinators
229                .get(&sink_id)
230                .is_some_and(|sink| Arc::ptr_eq(&sink.registration, &registration))
231                || registration.ready.swap(true, Ordering::AcqRel)
232            {
233                continue;
234            }
235            self.inner.compaction_manager.on_sink_ready(
236                sink_id,
237                registration,
238                coordinator.latest_observed_snapshot(),
239            );
240        }
241    }
242
243    pub(crate) fn on_sink_config_changed(&self, sink_id: SinkId, config: IcebergConfig) {
244        let mut coordinators = self.inner.coordinators.write();
245        if let Some(sink) = coordinators.get_mut(&sink_id) {
246            sink.registration.config.store(Arc::new(config));
247            if sink.registration.is_ready() {
248                self.inner
249                    .compaction_manager
250                    .on_sink_config_changed(sink_id, (*sink.registration.config()).clone());
251            }
252        }
253    }
254
255    /// Pre-commit one epoch, with an optional compaction overwrite folded into the same pending row.
256    /// The barrier-complete path awaits this before issuing Hummock `commit_epoch`.
257    pub(crate) async fn pre_commit(
258        &self,
259        input: IcebergPkIndexPreCommitMetadata,
260    ) -> anyhow::Result<()> {
261        let coordinator = self.coordinator(input.sink_id)?;
262        coordinator
263            .lock()
264            .await
265            .pre_commit(input.prev_epoch, input.reports, input.compaction)
266            .await
267    }
268
269    /// Commit phase for one epoch: run an iceberg `overwrite_files` transaction and mark its pending row
270    /// committed. The barrier-complete path awaits this AFTER hummock `commit_epoch`.
271    pub async fn commit_epoch(&self, sink_id: SinkId) -> anyhow::Result<()> {
272        let coordinator = self.coordinator(sink_id)?;
273        // Keep commit completion and scheduler notifications in epoch order.
274        let mut coordinator_guard = coordinator.lock().await;
275        let result = coordinator_guard.commit().await?;
276        if let Some((observed_snapshot, has_new_data)) = result {
277            let coordinators = self.inner.coordinators.read();
278            if let Some(sink) = coordinators.get(&sink_id)
279                && sink
280                    .coordinator
281                    .as_ref()
282                    .is_some_and(|current| Arc::ptr_eq(current, &coordinator))
283                && sink.registration.is_ready()
284            {
285                self.notify_compaction_scheduler(sink_id, Some(observed_snapshot), has_new_data);
286            }
287        }
288        drop(coordinator_guard);
289        Ok(())
290    }
291
292    fn notify_compaction_scheduler(
293        &self,
294        sink_id: SinkId,
295        observed_snapshot: Option<IcebergCommittedSnapshot>,
296        has_new_data: bool,
297    ) {
298        let Some(observed_snapshot) = observed_snapshot else {
299            return;
300        };
301        if self
302            .inner
303            .compaction_stat_tx
304            .send(IcebergSinkCompactionUpdate {
305                sink_id,
306                force_compaction: false,
307                has_new_data,
308                observed_snapshot,
309            })
310            .is_err()
311        {
312            warn!(%sink_id, "failed to notify iceberg compaction scheduler");
313        }
314    }
315
316    /// Advance the per-partial-graph committed epoch after a checkpoint completion.
317    pub fn advance_committed_epochs(
318        &self,
319        epochs: impl IntoIterator<Item = (PartialGraphId, u64)>,
320    ) {
321        self.inner.committed_epochs.advance_all(epochs);
322    }
323
324    /// Block until `sink_id`'s partial graph has committed through `target_epoch`, then return the
325    /// coordinator's committed iceberg snapshot id (a lower bound the caller must observe when it
326    /// reloads the table). Does NOT hold the coordinator lock while waiting.
327    pub async fn wait_epoch(
328        &self,
329        sink_id: SinkId,
330        target_epoch: u64,
331    ) -> anyhow::Result<Option<i64>> {
332        let partial_graph_id = self.partial_graph_of(sink_id)?;
333        self.inner
334            .committed_epochs
335            .wait(partial_graph_id, target_epoch)
336            .await;
337        let coordinator = self.coordinator(sink_id)?;
338        let snapshot_id = coordinator.lock().await.current_snapshot_id();
339        Ok(snapshot_id)
340    }
341
342    /// Permanently retire the sinks actually removed from the catalog, including legacy sinks
343    /// which only have compaction state. Idempotent, and serialized with ready publication.
344    pub fn retire_sinks(&self, sink_ids: impl IntoIterator<Item = SinkId>) {
345        let mut coordinators = self.inner.coordinators.write();
346        for sink_id in sink_ids {
347            Self::remove_sink(&mut coordinators, sink_id, &self.inner.committed_epochs);
348            self.inner
349                .compaction_manager
350                .clear_iceberg_maintenance_by_sink_id(sink_id);
351        }
352    }
353
354    pub(crate) fn start_recovery(&self, job_ids: Option<&HashSet<JobId>>) {
355        let mut coordinators = self.inner.coordinators.write();
356        if let Some(job_ids) = job_ids {
357            for job_id in job_ids {
358                Self::remove_sink(
359                    &mut coordinators,
360                    job_id.as_sink_id(),
361                    &self.inner.committed_epochs,
362                );
363            }
364        } else {
365            for sink in coordinators.values() {
366                sink.registration.ready.store(false, Ordering::Release);
367            }
368            coordinators.clear();
369            self.inner.committed_epochs.clear();
370        }
371        let scope = job_ids.map(|ids| ids.iter().map(|id| id.as_sink_id()).collect::<Vec<_>>());
372        self.inner
373            .compaction_manager
374            .on_recovery_start(scope.as_deref());
375    }
376
377    fn remove_sink(
378        coordinators: &mut HashMap<SinkId, RegisteredSink>,
379        sink_id: SinkId,
380        committed_epochs: &PartialGraphCommittedEpochs,
381    ) {
382        if let Some(sink) = coordinators.remove(&sink_id) {
383            sink.registration.ready.store(false, Ordering::Release);
384            if !coordinators
385                .values()
386                .any(|other| other.partial_graph_id == sink.partial_graph_id)
387            {
388                committed_epochs.remove(sink.partial_graph_id);
389            }
390        }
391    }
392
393    fn coordinator(&self, sink_id: SinkId) -> anyhow::Result<CoordinatorRef> {
394        self.inner
395            .coordinators
396            .read()
397            .get(&sink_id)
398            .and_then(|sink| sink.coordinator.clone())
399            .ok_or_else(|| {
400                anyhow!(
401                    "iceberg pk-index sink coordinator for sink {} is not registered",
402                    sink_id
403                )
404            })
405    }
406
407    fn partial_graph_of(&self, sink_id: SinkId) -> anyhow::Result<PartialGraphId> {
408        self.inner
409            .coordinators
410            .read()
411            .get(&sink_id)
412            .map(|sink| sink.partial_graph_id)
413            .ok_or_else(|| {
414                anyhow!(
415                    "iceberg pk-index sink coordinator for sink {} is not registered",
416                    sink_id
417                )
418            })
419    }
420}
421
422#[cfg(test)]
423#[path = "manager_test.rs"]
424mod tests;