Skip to main content

risingwave_meta/stream/
refresh_manager.rs

1// Copyright 2025 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::{Duration, Instant};
18
19use anyhow::anyhow;
20use chrono::{DateTime, Duration as ChronoDuration, NaiveDateTime, Utc};
21use parking_lot::Mutex;
22use risingwave_common::catalog::{DatabaseId, FragmentTypeFlag, TableId};
23use risingwave_common::metrics::{LabelGuardedIntCounter, LabelGuardedUintGauge};
24use risingwave_common::util::epoch::Epoch;
25use risingwave_meta_model::ActorId;
26use risingwave_meta_model::refresh_job::{self, RefreshState};
27use risingwave_pb::catalog::table::OptionalAssociatedSourceId;
28use risingwave_pb::id::SourceId;
29use risingwave_pb::meta::{RefreshRequest, RefreshResponse};
30use thiserror_ext::AsReport;
31use tokio::sync::{Notify, oneshot};
32use tokio::task::JoinHandle;
33
34use super::ScaleControllerRef;
35use crate::barrier::{BarrierScheduler, Command};
36use crate::controller::fragment::InflightFragmentInfo;
37use crate::manager::MetadataManager;
38use crate::rpc::metrics::GLOBAL_META_METRICS;
39use crate::{MetaError, MetaResult};
40
41pub type GlobalRefreshManagerRef = Arc<GlobalRefreshManager>;
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44enum TriggerReason {
45    Manual,
46    Cron,
47    RetryAfterAbort,
48}
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum RefreshStage {
52    List,
53    Fetch,
54    Mview,
55}
56
57/// The actors of a refreshable table's job, grouped by the stage they report.
58#[derive(Debug, Default)]
59pub struct RefreshCycleActors {
60    pub list: HashSet<ActorId>,
61    pub fetch: HashSet<ActorId>,
62    pub mview: HashSet<ActorId>,
63}
64
65impl RefreshCycleActors {
66    pub fn from_fragments<'a>(fragments: impl Iterator<Item = &'a InflightFragmentInfo>) -> Self {
67        let mut actors = Self::default();
68        for fragment in fragments {
69            let mask = fragment.fragment_type_mask;
70            let stage = if mask.contains(FragmentTypeFlag::Mview) {
71                &mut actors.mview
72            } else if mask.contains(FragmentTypeFlag::Source)
73                && !mask.contains(FragmentTypeFlag::Dml)
74            {
75                &mut actors.list
76            } else if mask.contains(FragmentTypeFlag::FsFetch) {
77                &mut actors.fetch
78            } else {
79                continue;
80            };
81            stage.extend(fragment.actors.keys().map(|actor_id| *actor_id as ActorId));
82        }
83        actors
84    }
85}
86
87/// Owns the refresh cycles. The `refresh_job` row is the source of truth: a cycle is its
88/// `last_trigger_time`, and both transitions are the post-collect of a barrier (`RefreshStart` moves
89/// the job to `Refreshing`, `FinishRefresh` back to `Idle`). A recovery kills every cycle of the
90/// databases it re-initializes and moves their rows back to `Idle` before those databases accept
91/// commands again (`abandon_cycles`), so nothing has to infer an abandoned cycle afterwards. The
92/// in-memory tracker of a cycle is installed by the same barrier that started it, before the row
93/// moves.
94pub struct GlobalRefreshManager {
95    metadata_manager: MetadataManager,
96    barrier_scheduler: BarrierScheduler,
97    scale_controller: ScaleControllerRef,
98    cycles: Mutex<Cycles>,
99    scheduler_wakeup: Notify,
100}
101
102#[derive(Default)]
103struct Cycles {
104    trackers: HashMap<TableId, CycleTracker>,
105    /// Tables whose `RefreshStart` barrier is being scheduled.
106    starting: HashSet<TableId>,
107    retry: HashMap<TableId, RetryState>,
108    metrics: HashMap<TableId, RefreshJobMetrics>,
109}
110
111/// An abandoned cycle is re-run once; the state is cleared by a successful cycle.
112#[derive(Clone, Copy, Debug, PartialEq, Eq)]
113enum RetryState {
114    Owed,
115    Used,
116}
117
118impl GlobalRefreshManager {
119    pub async fn start(
120        metadata_manager: MetadataManager,
121        barrier_scheduler: BarrierScheduler,
122        scale_controller: ScaleControllerRef,
123        scheduler_interval: Duration,
124    ) -> MetaResult<(GlobalRefreshManagerRef, JoinHandle<()>, oneshot::Sender<()>)> {
125        for table_id in metadata_manager.list_refreshable_table_ids().await? {
126            metadata_manager.ensure_refresh_job(table_id).await?;
127        }
128        let manager = Arc::new(Self {
129            metadata_manager,
130            barrier_scheduler,
131            scale_controller,
132            cycles: Mutex::new(Cycles::default()),
133            scheduler_wakeup: Notify::new(),
134        });
135        let (shutdown_tx, shutdown_rx) = oneshot::channel();
136        let join_handle = tokio::spawn(
137            manager
138                .clone()
139                .run_scheduler(shutdown_rx, scheduler_interval),
140        );
141        Ok((manager, join_handle, shutdown_tx))
142    }
143
144    /// Returns once the `RefreshStart` barrier of the cycle is collected.
145    pub async fn trigger_manual_refresh(
146        self: &Arc<Self>,
147        request: RefreshRequest,
148    ) -> MetaResult<RefreshResponse> {
149        let table_id = request.table_id;
150        let associated_source_id = request.associated_source_id;
151        tracing::info!(%table_id, %associated_source_id, "trigger manual refresh");
152        self.trigger_cycle(table_id, Some(associated_source_id), TriggerReason::Manual)
153            .await?;
154        Ok(RefreshResponse { status: None })
155    }
156
157    /// The cycle is started by a spawned task, so a caller that goes away does not release the
158    /// reschedule guard before the `RefreshStart` barrier is collected.
159    async fn trigger_cycle(
160        self: &Arc<Self>,
161        table_id: TableId,
162        associated_source_id: Option<SourceId>,
163        reason: TriggerReason,
164    ) -> MetaResult<()> {
165        {
166            let mut cycles = self.cycles.lock();
167            if cycles.trackers.contains_key(&table_id) || !cycles.starting.insert(table_id) {
168                return Err(MetaError::invalid_parameter(format!(
169                    "Table {} is not in idle state: a refresh cycle is in progress",
170                    table_id
171                )));
172            }
173        }
174        let this = self.clone();
175        let result = tokio::spawn(async move {
176            let result = this
177                .start_cycle(table_id, associated_source_id, reason)
178                .await;
179            this.cycles.lock().starting.remove(&table_id);
180            result
181        })
182        .await;
183        result.map_err(|err| anyhow!(err))?
184    }
185
186    async fn start_cycle(
187        &self,
188        table_id: TableId,
189        associated_source_id: Option<SourceId>,
190        reason: TriggerReason,
191    ) -> MetaResult<()> {
192        let catalog = &self.metadata_manager.catalog_controller;
193        let table = catalog.get_table_by_id(table_id).await?;
194        if !table.refreshable {
195            return Err(MetaError::invalid_parameter(format!(
196                "Table '{}' is not refreshable. Only tables created with REFRESHABLE flag support refresh.",
197                table.name
198            )));
199        }
200        let Some(OptionalAssociatedSourceId::AssociatedSourceId(table_source_id)) =
201            table.optional_associated_source_id
202        else {
203            return Err(MetaError::invalid_parameter(format!(
204                "Table '{}' has no associated source",
205                table.name
206            )));
207        };
208        let associated_source_id = associated_source_id.unwrap_or(table_source_id);
209        if associated_source_id != table_source_id {
210            return Err(MetaError::invalid_parameter(format!(
211                "Table '{}' is not associated with source '{}'. table.optional_associated_source_id: {:?}",
212                table.name, associated_source_id, table.optional_associated_source_id
213            )));
214        }
215        if let Some(state) = catalog.get_refresh_job_state(table_id).await?
216            && state != RefreshState::Idle
217        {
218            return Err(MetaError::invalid_parameter(format!(
219                "Table '{}' is not in idle state. Current state: {}",
220                table.name, state
221            )));
222        }
223        let database_id = catalog.get_object_database_id(table_id).await?;
224        let staging_table_id = catalog
225            .get_refresh_staging_table_id(table_id.as_job_id())
226            .await?
227            .ok_or_else(|| anyhow!("staging table not found for refreshable table {}", table_id))?;
228
229        // Like a creating job: no reschedule or replacement of the table between the check above
230        // and the collection of the barrier, after which the job is `Refreshing`.
231        let _reschedule_guard = self.scale_controller.reschedule_lock.read().await;
232        let trigger_time = now_millis();
233        tracing::info!(%table_id, %trigger_time, ?reason, "scheduling refresh cycle");
234        self.barrier_scheduler
235            .schedule_command(
236                database_id,
237                Command::Refresh {
238                    table_id,
239                    associated_source_id,
240                    staging_table_id,
241                    trigger_time,
242                },
243            )?
244            .await?;
245
246        let mut cycles = self.cycles.lock();
247        if !cycles
248            .trackers
249            .get(&table_id)
250            .is_some_and(|tracker| tracker.trigger_time == trigger_time)
251        {
252            return Err(anyhow!(
253                "refresh of table {} is not running: the table was dropped or a recovery abandoned the cycle",
254                table_id
255            )
256            .into());
257        }
258        match reason {
259            TriggerReason::Manual => {}
260            TriggerReason::Cron => cycles.job_metrics(table_id).cron_trigger_count.inc(),
261            TriggerReason::RetryAfterAbort => {
262                cycles.retry.insert(table_id, RetryState::Used);
263            }
264        }
265        Ok(())
266    }
267
268    /// Post-collect of the `RefreshStart` barrier.
269    pub async fn cycle_started(
270        &self,
271        table_id: TableId,
272        database_id: DatabaseId,
273        associated_source_id: SourceId,
274        staging_table_id: TableId,
275        trigger_time: NaiveDateTime,
276        actors: RefreshCycleActors,
277    ) -> MetaResult<()> {
278        // Installed before the row moves, so the row never says `Refreshing` without it.
279        self.cycles.lock().trackers.insert(
280            table_id,
281            CycleTracker::new(
282                database_id,
283                associated_source_id,
284                staging_table_id,
285                trigger_time,
286                actors,
287            ),
288        );
289        if !self
290            .metadata_manager
291            .begin_refresh_job(table_id, trigger_time)
292            .await?
293        {
294            let mut cycles = self.cycles.lock();
295            if cycles
296                .trackers
297                .get(&table_id)
298                .is_some_and(|tracker| tracker.trigger_time == trigger_time)
299            {
300                cycles.trackers.remove(&table_id);
301            }
302            tracing::warn!(%table_id, %trigger_time, "RefreshStart collected for a table that is not idle, the cycle is not tracked");
303            return Ok(());
304        }
305        tracing::info!(%table_id, %trigger_time, "refresh cycle started");
306        Ok(())
307    }
308
309    /// Schedules the next stage once every actor of the stage reported; an unexpected reporter
310    /// fails the barrier, which abandons the cycle through a recovery.
311    pub fn report_stage(
312        &self,
313        table_id: TableId,
314        stage: RefreshStage,
315        actors: HashSet<ActorId>,
316    ) -> MetaResult<()> {
317        let mut cycles = self.cycles.lock();
318        let Some(tracker) = cycles.trackers.get_mut(&table_id) else {
319            // The cycle was abandoned or the table dropped meanwhile.
320            tracing::warn!(%table_id, ?stage, ?actors, "ignore refresh stage report without tracker");
321            return Ok(());
322        };
323        if !tracker.stage_mut(stage).report(stage, &actors)? {
324            return Ok(());
325        }
326        let (database_id, associated_source_id, staging_table_id, trigger_time) = (
327            tracker.database_id,
328            tracker.associated_source_id,
329            tracker.staging_table_id,
330            tracker.trigger_time,
331        );
332        let command = match stage {
333            RefreshStage::List => Command::ListFinish {
334                table_id,
335                associated_source_id,
336            },
337            RefreshStage::Fetch => Command::LoadFinish {
338                table_id,
339                associated_source_id,
340            },
341            RefreshStage::Mview => {
342                let command = Command::FinishRefresh {
343                    table_id,
344                    staging_table_id,
345                    trigger_time,
346                };
347                // The queue only refuses a command while the database is recovering, and that
348                // recovery abandons the cycle itself.
349                match self
350                    .barrier_scheduler
351                    .run_command_no_wait(database_id, command)
352                {
353                    Ok(()) => {
354                        tracing::info!(%table_id, %trigger_time, "FinishRefresh command scheduled")
355                    }
356                    Err(err) => {
357                        tracing::warn!(%table_id, error = %err.as_report(), "failed to schedule FinishRefresh, the recovery in progress abandons the cycle")
358                    }
359                }
360                return Ok(());
361            }
362        };
363        self.barrier_scheduler
364            .run_command_no_wait(database_id, command)
365            .map_err(|err| {
366                anyhow!(err).context(format!(
367                    "failed to schedule the next refresh stage of table {table_id}"
368                ))
369            })?;
370        tracing::info!(%table_id, ?stage, "refresh stage finished");
371        Ok(())
372    }
373
374    /// Post-collect of the `FinishRefresh` barrier.
375    pub async fn complete_refresh(
376        &self,
377        table_id: TableId,
378        trigger_time: NaiveDateTime,
379    ) -> MetaResult<()> {
380        let finished = self
381            .metadata_manager
382            .finish_refresh_job(table_id, trigger_time, true)
383            .await?;
384        let mut cycles = self.cycles.lock();
385        let tracker = cycles
386            .trackers
387            .get(&table_id)
388            .is_some_and(|tracker| tracker.trigger_time == trigger_time)
389            .then(|| cycles.trackers.remove(&table_id))
390            .flatten();
391        if !finished {
392            tracing::warn!(%table_id, %trigger_time, "FinishRefresh collected for a cycle that is no longer current");
393            return Ok(());
394        }
395        tracing::info!(%table_id, %trigger_time, aborted = false, "refresh cycle finished");
396        cycles.record_finished(
397            table_id,
398            "success",
399            cycle_duration_secs(tracker.as_ref(), trigger_time),
400        );
401        cycles.retry.remove(&table_id);
402        Ok(())
403    }
404
405    /// Called while a recovery re-initializes the database(s), before they accept commands again:
406    /// every cycle of theirs is dead, so its row moves back to `Idle` here and it is re-run once.
407    pub async fn abandon_cycles(&self, database_id: Option<DatabaseId>) -> MetaResult<()> {
408        let jobs = self
409            .metadata_manager
410            .list_refreshing_jobs(database_id)
411            .await?;
412        let mut abandoned = Vec::with_capacity(jobs.len());
413        for job in jobs {
414            let Some(trigger_time) = job.last_trigger_time.map(millis_to_datetime) else {
415                tracing::error!(table_id = %job.table_id, status = ?job.current_status, "refresh job is not idle but has no trigger time");
416                continue;
417            };
418            if self
419                .metadata_manager
420                .finish_refresh_job(job.table_id, trigger_time, false)
421                .await?
422            {
423                abandoned.push((job.table_id, trigger_time));
424            }
425        }
426        let mut cycles = self.cycles.lock();
427        for (table_id, trigger_time) in abandoned {
428            let tracker = cycles.trackers.remove(&table_id);
429            tracing::info!(%table_id, %trigger_time, aborted = true, "refresh cycle finished");
430            cycles.record_finished(
431                table_id,
432                "aborted",
433                cycle_duration_secs(tracker.as_ref(), trigger_time),
434            );
435            if cycles.retry.get(&table_id) == Some(&RetryState::Used) {
436                tracing::warn!(%table_id, "abandoned refresh was already re-run once, waiting for the next trigger");
437            } else {
438                cycles.retry.insert(table_id, RetryState::Owed);
439            }
440        }
441        match database_id {
442            None => cycles.trackers.clear(),
443            Some(database_id) => cycles
444                .trackers
445                .retain(|_, tracker| tracker.database_id != database_id),
446        }
447        Ok(())
448    }
449
450    /// Called when the table is dropped; `status` labels the metrics of an interrupted cycle.
451    pub fn remove_progress_tracker(&self, table_id: TableId, status: &str) {
452        let mut cycles = self.cycles.lock();
453        if let Some(tracker) = cycles.trackers.remove(&table_id) {
454            cycles.record_finished(table_id, status, tracker.start_time.elapsed().as_secs());
455        }
456        cycles.retry.remove(&table_id);
457    }
458
459    pub fn notify_scheduler(&self) {
460        self.scheduler_wakeup.notify_one();
461    }
462
463    async fn run_scheduler(
464        self: Arc<Self>,
465        mut shutdown_rx: oneshot::Receiver<()>,
466        scheduler_interval: Duration,
467    ) {
468        let mut ticker = tokio::time::interval(scheduler_interval);
469        loop {
470            tokio::select! {
471                biased;
472                _ = &mut shutdown_rx => {
473                    tracing::info!("refresh scheduler shutting down");
474                    break;
475                }
476                _ = self.scheduler_wakeup.notified() => {}
477                _ = ticker.tick() => {}
478            }
479            self.tick().await;
480        }
481    }
482
483    async fn tick(self: &Arc<Self>) {
484        let jobs = match self.metadata_manager.list_refresh_jobs().await {
485            Ok(jobs) => jobs,
486            Err(err) => {
487                tracing::warn!(error = %err.as_report(), "failed to list refresh jobs");
488                return;
489            }
490        };
491        let never_triggered: Vec<TableId> = jobs
492            .iter()
493            .filter(|job| job.last_trigger_time.is_none() && cron_interval(job).is_some())
494            .map(|job| job.table_id)
495            .collect();
496        let created_at = match self.created_at_millis(&never_triggered).await {
497            Ok(created_at) => created_at,
498            Err(err) => {
499                tracing::warn!(error = %err.as_report(), "failed to load the creation time of refreshable tables");
500                HashMap::new()
501            }
502        };
503        {
504            let active: HashSet<_> = jobs.iter().map(|job| job.table_id).collect();
505            let mut cycles = self.cycles.lock();
506            cycles
507                .metrics
508                .retain(|table_id, _| active.contains(table_id));
509            cycles.retry.retain(|table_id, _| active.contains(table_id));
510        }
511        for job in &jobs {
512            let table_id = job.table_id;
513            let cron_due = cron_due(job, created_at.get(&table_id).copied());
514            if job.current_status != RefreshState::Idle {
515                if cron_due {
516                    let mut cycles = self.cycles.lock();
517                    cycles.job_metrics(table_id).cron_miss_count.inc();
518                    tracing::warn!(%table_id, status = ?job.current_status, "skip scheduled refresh: the previous cycle is still running");
519                }
520                continue;
521            }
522            let reason = {
523                let cycles = self.cycles.lock();
524                if cycles.starting.contains(&table_id) {
525                    continue;
526                }
527                if cycles.retry.get(&table_id) == Some(&RetryState::Owed) {
528                    TriggerReason::RetryAfterAbort
529                } else if cron_due {
530                    TriggerReason::Cron
531                } else {
532                    continue;
533                }
534            };
535            tracing::info!(%table_id, ?reason, "trigger refresh");
536            let this = self.clone();
537            tokio::spawn(async move {
538                if let Err(err) = this.trigger_cycle(table_id, None, reason).await {
539                    tracing::warn!(%table_id, ?reason, error = %err.as_report(), "failed to trigger refresh");
540                }
541            });
542        }
543    }
544
545    async fn created_at_millis(&self, table_ids: &[TableId]) -> MetaResult<HashMap<TableId, i64>> {
546        if table_ids.is_empty() {
547            return Ok(HashMap::new());
548        }
549        Ok(self
550            .metadata_manager
551            .get_table_catalog_by_ids(table_ids)
552            .await?
553            .into_iter()
554            .map(|table| {
555                (
556                    table.id,
557                    Epoch(table.created_at_epoch())
558                        .as_timestamptz()
559                        .to_datetime_utc()
560                        .timestamp_millis(),
561                )
562            })
563            .collect())
564    }
565}
566
567impl Cycles {
568    fn record_finished(&mut self, table_id: TableId, status: &str, duration_secs: u64) {
569        let finished = self
570            .job_metrics(table_id)
571            .finished
572            .entry(status.to_owned())
573            .or_insert_with(|| RefreshFinishedMetrics::new(table_id, status));
574        finished.count.inc();
575        finished.duration.set(duration_secs);
576    }
577
578    fn job_metrics(&mut self, table_id: TableId) -> &mut RefreshJobMetrics {
579        self.metrics
580            .entry(table_id)
581            .or_insert_with(|| RefreshJobMetrics::new(table_id))
582    }
583}
584
585fn cron_interval(job: &refresh_job::Model) -> Option<ChronoDuration> {
586    job.trigger_interval_secs
587        .filter(|secs| *secs > 0)
588        .map(ChronoDuration::seconds)
589}
590
591/// `created_at_millis` stands in for the last trigger time of a job that never ran.
592fn cron_due(job: &refresh_job::Model, created_at_millis: Option<i64>) -> bool {
593    let Some(interval) = cron_interval(job) else {
594        return false;
595    };
596    let Some(last_run) = job.last_trigger_time.or(created_at_millis) else {
597        return false;
598    };
599    Utc::now()
600        .naive_utc()
601        .signed_duration_since(millis_to_datetime(last_run))
602        >= interval
603}
604
605/// Truncated to the millisecond precision of the persisted trigger time.
606fn now_millis() -> NaiveDateTime {
607    millis_to_datetime(Utc::now().timestamp_millis())
608}
609
610/// Wall-clock length of a cycle; falls back to the trigger time when the tracker did not survive a
611/// meta restart.
612fn cycle_duration_secs(tracker: Option<&CycleTracker>, trigger_time: NaiveDateTime) -> u64 {
613    tracker.map_or_else(
614        || u64::try_from((Utc::now().naive_utc() - trigger_time).num_seconds()).unwrap_or(0),
615        |tracker| tracker.start_time.elapsed().as_secs(),
616    )
617}
618
619fn millis_to_datetime(millis: i64) -> NaiveDateTime {
620    DateTime::from_timestamp_millis(millis)
621        .expect("valid timestamp")
622        .naive_utc()
623}
624
625struct RefreshJobMetrics {
626    cron_trigger_count: LabelGuardedIntCounter,
627    cron_miss_count: LabelGuardedIntCounter,
628    finished: HashMap<String, RefreshFinishedMetrics>,
629}
630
631impl RefreshJobMetrics {
632    fn new(table_id: TableId) -> Self {
633        let table_id = table_id.to_string();
634        Self {
635            cron_trigger_count: GLOBAL_META_METRICS
636                .refresh_cron_job_trigger_cnt
637                .with_guarded_label_values(&[&table_id]),
638            cron_miss_count: GLOBAL_META_METRICS
639                .refresh_cron_job_miss_cnt
640                .with_guarded_label_values(&[&table_id]),
641            finished: HashMap::new(),
642        }
643    }
644}
645
646struct RefreshFinishedMetrics {
647    count: LabelGuardedIntCounter,
648    duration: LabelGuardedUintGauge,
649}
650
651impl RefreshFinishedMetrics {
652    fn new(table_id: TableId, status: &str) -> Self {
653        let table_id = table_id.to_string();
654        Self {
655            count: GLOBAL_META_METRICS
656                .refresh_job_finish_cnt
657                .with_guarded_label_values(&[&table_id, status]),
658            duration: GLOBAL_META_METRICS
659                .refresh_job_duration
660                .with_guarded_label_values(&[&table_id, status]),
661        }
662    }
663}
664
665#[derive(Debug)]
666struct CycleTracker {
667    database_id: DatabaseId,
668    associated_source_id: SourceId,
669    staging_table_id: TableId,
670    trigger_time: NaiveDateTime,
671    list: StageProgress,
672    fetch: StageProgress,
673    mview: StageProgress,
674    start_time: Instant,
675}
676
677impl CycleTracker {
678    fn new(
679        database_id: DatabaseId,
680        associated_source_id: SourceId,
681        staging_table_id: TableId,
682        trigger_time: NaiveDateTime,
683        actors: RefreshCycleActors,
684    ) -> Self {
685        Self {
686            database_id,
687            associated_source_id,
688            staging_table_id,
689            trigger_time,
690            list: StageProgress::new(actors.list),
691            fetch: StageProgress::new(actors.fetch),
692            mview: StageProgress::new(actors.mview),
693            start_time: Instant::now(),
694        }
695    }
696
697    fn stage_mut(&mut self, stage: RefreshStage) -> &mut StageProgress {
698        match stage {
699            RefreshStage::List => &mut self.list,
700            RefreshStage::Fetch => &mut self.fetch,
701            RefreshStage::Mview => &mut self.mview,
702        }
703    }
704}
705
706#[derive(Debug)]
707struct StageProgress {
708    expected: HashSet<ActorId>,
709    finished: HashSet<ActorId>,
710}
711
712impl StageProgress {
713    fn new(expected: HashSet<ActorId>) -> Self {
714        Self {
715            expected,
716            finished: HashSet::new(),
717        }
718    }
719
720    /// True once every expected actor reported; an unexpected reporter is an error.
721    fn report(&mut self, stage: RefreshStage, actors: &HashSet<ActorId>) -> MetaResult<bool> {
722        self.finished.extend(actors);
723        if self.finished.len() < self.expected.len() {
724            return Ok(false);
725        }
726        if self.finished == self.expected {
727            Ok(true)
728        } else {
729            Err(anyhow!(
730                "{stage:?} finished actors mismatch: expected: {:?}, actual: {:?}",
731                self.expected,
732                self.finished
733            )
734            .into())
735        }
736    }
737}