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        // Like a creating job: no reschedule or replacement of the table (e.g. `DROP CONNECTOR`)
193        // between the checks below and the collection of the barrier, after which the job is
194        // `Refreshing`.
195        let _reschedule_guard = self.scale_controller.reschedule_lock.read().await;
196        let catalog = &self.metadata_manager.catalog_controller;
197        let table = catalog.get_table_by_id(table_id).await?;
198        if !table.refreshable {
199            return Err(MetaError::invalid_parameter(format!(
200                "Table '{}' is not refreshable. Only tables created with REFRESHABLE flag support refresh.",
201                table.name
202            )));
203        }
204        let Some(OptionalAssociatedSourceId::AssociatedSourceId(table_source_id)) =
205            table.optional_associated_source_id
206        else {
207            return Err(MetaError::invalid_parameter(format!(
208                "Table '{}' has no associated source",
209                table.name
210            )));
211        };
212        let associated_source_id = associated_source_id.unwrap_or(table_source_id);
213        if associated_source_id != table_source_id {
214            return Err(MetaError::invalid_parameter(format!(
215                "Table '{}' is not associated with source '{}'. table.optional_associated_source_id: {:?}",
216                table.name, associated_source_id, table.optional_associated_source_id
217            )));
218        }
219        if let Some(state) = catalog.get_refresh_job_state(table_id).await?
220            && state != RefreshState::Idle
221        {
222            return Err(MetaError::invalid_parameter(format!(
223                "Table '{}' is not in idle state. Current state: {}",
224                table.name, state
225            )));
226        }
227        let database_id = catalog.get_object_database_id(table_id).await?;
228        let staging_table_id = catalog
229            .get_refresh_staging_table_id(table_id.as_job_id())
230            .await?
231            .ok_or_else(|| anyhow!("staging table not found for refreshable table {}", table_id))?;
232
233        let trigger_time = now_millis();
234        tracing::info!(%table_id, %trigger_time, ?reason, "scheduling refresh cycle");
235        self.barrier_scheduler
236            .schedule_command(
237                database_id,
238                Command::Refresh {
239                    table_id,
240                    associated_source_id,
241                    staging_table_id,
242                    trigger_time,
243                },
244            )?
245            .await?;
246
247        let mut cycles = self.cycles.lock();
248        if !cycles
249            .trackers
250            .get(&table_id)
251            .is_some_and(|tracker| tracker.trigger_time == trigger_time)
252        {
253            return Err(anyhow!(
254                "refresh of table {} is not running: the table was dropped or a recovery abandoned the cycle",
255                table_id
256            )
257            .into());
258        }
259        match reason {
260            TriggerReason::Manual => {}
261            TriggerReason::Cron => cycles.job_metrics(table_id).cron_trigger_count.inc(),
262            TriggerReason::RetryAfterAbort => {
263                cycles.retry.insert(table_id, RetryState::Used);
264            }
265        }
266        Ok(())
267    }
268
269    /// Post-collect of the `RefreshStart` barrier.
270    pub async fn cycle_started(
271        &self,
272        table_id: TableId,
273        database_id: DatabaseId,
274        associated_source_id: SourceId,
275        staging_table_id: TableId,
276        trigger_time: NaiveDateTime,
277        actors: RefreshCycleActors,
278    ) -> MetaResult<()> {
279        // Installed before the row moves, so the row never says `Refreshing` without it.
280        self.cycles.lock().trackers.insert(
281            table_id,
282            CycleTracker::new(
283                database_id,
284                associated_source_id,
285                staging_table_id,
286                trigger_time,
287                actors,
288            ),
289        );
290        if !self
291            .metadata_manager
292            .begin_refresh_job(table_id, trigger_time)
293            .await?
294        {
295            let mut cycles = self.cycles.lock();
296            if cycles
297                .trackers
298                .get(&table_id)
299                .is_some_and(|tracker| tracker.trigger_time == trigger_time)
300            {
301                cycles.trackers.remove(&table_id);
302            }
303            tracing::warn!(%table_id, %trigger_time, "RefreshStart collected for a table that is not idle, the cycle is not tracked");
304            return Ok(());
305        }
306        tracing::info!(%table_id, %trigger_time, "refresh cycle started");
307        Ok(())
308    }
309
310    /// Schedules the next stage once every actor of the stage reported; an unexpected reporter
311    /// fails the barrier, which abandons the cycle through a recovery.
312    pub fn report_stage(
313        &self,
314        table_id: TableId,
315        stage: RefreshStage,
316        actors: HashSet<ActorId>,
317    ) -> MetaResult<()> {
318        let mut cycles = self.cycles.lock();
319        let Some(tracker) = cycles.trackers.get_mut(&table_id) else {
320            // The cycle was abandoned or the table dropped meanwhile.
321            tracing::warn!(%table_id, ?stage, ?actors, "ignore refresh stage report without tracker");
322            return Ok(());
323        };
324        if !tracker.stage_mut(stage).report(stage, &actors)? {
325            return Ok(());
326        }
327        let (database_id, associated_source_id, staging_table_id, trigger_time) = (
328            tracker.database_id,
329            tracker.associated_source_id,
330            tracker.staging_table_id,
331            tracker.trigger_time,
332        );
333        let command = match stage {
334            RefreshStage::List => Command::ListFinish {
335                table_id,
336                associated_source_id,
337            },
338            RefreshStage::Fetch => Command::LoadFinish {
339                table_id,
340                associated_source_id,
341            },
342            RefreshStage::Mview => {
343                let command = Command::FinishRefresh {
344                    table_id,
345                    staging_table_id,
346                    trigger_time,
347                };
348                // The queue only refuses a command while the database is recovering, and that
349                // recovery abandons the cycle itself.
350                match self
351                    .barrier_scheduler
352                    .run_command_no_wait(database_id, command)
353                {
354                    Ok(()) => {
355                        tracing::info!(%table_id, %trigger_time, "FinishRefresh command scheduled")
356                    }
357                    Err(err) => {
358                        tracing::warn!(%table_id, error = %err.as_report(), "failed to schedule FinishRefresh, the recovery in progress abandons the cycle")
359                    }
360                }
361                return Ok(());
362            }
363        };
364        self.barrier_scheduler
365            .run_command_no_wait(database_id, command)
366            .map_err(|err| {
367                anyhow!(err).context(format!(
368                    "failed to schedule the next refresh stage of table {table_id}"
369                ))
370            })?;
371        tracing::info!(%table_id, ?stage, "refresh stage finished");
372        Ok(())
373    }
374
375    /// Post-collect of the `FinishRefresh` barrier.
376    pub async fn complete_refresh(
377        &self,
378        table_id: TableId,
379        trigger_time: NaiveDateTime,
380    ) -> MetaResult<()> {
381        let finished = self
382            .metadata_manager
383            .finish_refresh_job(table_id, trigger_time, true)
384            .await?;
385        let mut cycles = self.cycles.lock();
386        let tracker = cycles
387            .trackers
388            .get(&table_id)
389            .is_some_and(|tracker| tracker.trigger_time == trigger_time)
390            .then(|| cycles.trackers.remove(&table_id))
391            .flatten();
392        if !finished {
393            tracing::warn!(%table_id, %trigger_time, "FinishRefresh collected for a cycle that is no longer current");
394            return Ok(());
395        }
396        tracing::info!(%table_id, %trigger_time, aborted = false, "refresh cycle finished");
397        cycles.record_finished(
398            table_id,
399            "success",
400            cycle_duration_secs(tracker.as_ref(), trigger_time),
401        );
402        cycles.retry.remove(&table_id);
403        Ok(())
404    }
405
406    /// Called while a recovery re-initializes the database(s), before they accept commands again:
407    /// every cycle of theirs is dead, so its row moves back to `Idle` here and it is re-run once.
408    pub async fn abandon_cycles(&self, database_id: Option<DatabaseId>) -> MetaResult<()> {
409        let jobs = self
410            .metadata_manager
411            .list_refreshing_jobs(database_id)
412            .await?;
413        let mut abandoned = Vec::with_capacity(jobs.len());
414        for job in jobs {
415            let Some(trigger_time) = job.last_trigger_time.map(millis_to_datetime) else {
416                tracing::error!(table_id = %job.table_id, status = ?job.current_status, "refresh job is not idle but has no trigger time");
417                continue;
418            };
419            if self
420                .metadata_manager
421                .finish_refresh_job(job.table_id, trigger_time, false)
422                .await?
423            {
424                abandoned.push((job.table_id, trigger_time));
425            }
426        }
427        let mut cycles = self.cycles.lock();
428        for (table_id, trigger_time) in abandoned {
429            let tracker = cycles.trackers.remove(&table_id);
430            tracing::info!(%table_id, %trigger_time, aborted = true, "refresh cycle finished");
431            cycles.record_finished(
432                table_id,
433                "aborted",
434                cycle_duration_secs(tracker.as_ref(), trigger_time),
435            );
436            if cycles.retry.get(&table_id) == Some(&RetryState::Used) {
437                tracing::warn!(%table_id, "abandoned refresh was already re-run once, waiting for the next trigger");
438            } else {
439                cycles.retry.insert(table_id, RetryState::Owed);
440            }
441        }
442        match database_id {
443            None => cycles.trackers.clear(),
444            Some(database_id) => cycles
445                .trackers
446                .retain(|_, tracker| tracker.database_id != database_id),
447        }
448        Ok(())
449    }
450
451    /// Called when the table is dropped; `status` labels the metrics of an interrupted cycle.
452    pub fn remove_progress_tracker(&self, table_id: TableId, status: &str) {
453        let mut cycles = self.cycles.lock();
454        if let Some(tracker) = cycles.trackers.remove(&table_id) {
455            cycles.record_finished(table_id, status, tracker.start_time.elapsed().as_secs());
456        }
457        cycles.retry.remove(&table_id);
458    }
459
460    pub fn notify_scheduler(&self) {
461        self.scheduler_wakeup.notify_one();
462    }
463
464    async fn run_scheduler(
465        self: Arc<Self>,
466        mut shutdown_rx: oneshot::Receiver<()>,
467        scheduler_interval: Duration,
468    ) {
469        let mut ticker = tokio::time::interval(scheduler_interval);
470        loop {
471            tokio::select! {
472                biased;
473                _ = &mut shutdown_rx => {
474                    tracing::info!("refresh scheduler shutting down");
475                    break;
476                }
477                _ = self.scheduler_wakeup.notified() => {}
478                _ = ticker.tick() => {}
479            }
480            self.tick().await;
481        }
482    }
483
484    async fn tick(self: &Arc<Self>) {
485        let jobs = match self.metadata_manager.list_refresh_jobs().await {
486            Ok(jobs) => jobs,
487            Err(err) => {
488                tracing::warn!(error = %err.as_report(), "failed to list refresh jobs");
489                return;
490            }
491        };
492        let never_triggered: Vec<TableId> = jobs
493            .iter()
494            .filter(|job| job.last_trigger_time.is_none() && cron_interval(job).is_some())
495            .map(|job| job.table_id)
496            .collect();
497        let created_at = match self.created_at_millis(&never_triggered).await {
498            Ok(created_at) => created_at,
499            Err(err) => {
500                tracing::warn!(error = %err.as_report(), "failed to load the creation time of refreshable tables");
501                HashMap::new()
502            }
503        };
504        {
505            let active: HashSet<_> = jobs.iter().map(|job| job.table_id).collect();
506            let mut cycles = self.cycles.lock();
507            cycles
508                .metrics
509                .retain(|table_id, _| active.contains(table_id));
510            cycles.retry.retain(|table_id, _| active.contains(table_id));
511        }
512        for job in &jobs {
513            let table_id = job.table_id;
514            let cron_due = cron_due(job, created_at.get(&table_id).copied());
515            if job.current_status != RefreshState::Idle {
516                if cron_due {
517                    let mut cycles = self.cycles.lock();
518                    cycles.job_metrics(table_id).cron_miss_count.inc();
519                    tracing::warn!(%table_id, status = ?job.current_status, "skip scheduled refresh: the previous cycle is still running");
520                }
521                continue;
522            }
523            let reason = {
524                let cycles = self.cycles.lock();
525                if cycles.starting.contains(&table_id) {
526                    continue;
527                }
528                if cycles.retry.get(&table_id) == Some(&RetryState::Owed) {
529                    TriggerReason::RetryAfterAbort
530                } else if cron_due {
531                    TriggerReason::Cron
532                } else {
533                    continue;
534                }
535            };
536            tracing::info!(%table_id, ?reason, "trigger refresh");
537            let this = self.clone();
538            tokio::spawn(async move {
539                if let Err(err) = this.trigger_cycle(table_id, None, reason).await {
540                    tracing::warn!(%table_id, ?reason, error = %err.as_report(), "failed to trigger refresh");
541                }
542            });
543        }
544    }
545
546    async fn created_at_millis(&self, table_ids: &[TableId]) -> MetaResult<HashMap<TableId, i64>> {
547        if table_ids.is_empty() {
548            return Ok(HashMap::new());
549        }
550        Ok(self
551            .metadata_manager
552            .get_table_catalog_by_ids(table_ids)
553            .await?
554            .into_iter()
555            .map(|table| {
556                (
557                    table.id,
558                    Epoch(table.created_at_epoch())
559                        .as_timestamptz()
560                        .to_datetime_utc()
561                        .timestamp_millis(),
562                )
563            })
564            .collect())
565    }
566}
567
568impl Cycles {
569    fn record_finished(&mut self, table_id: TableId, status: &str, duration_secs: u64) {
570        let finished = self
571            .job_metrics(table_id)
572            .finished
573            .entry(status.to_owned())
574            .or_insert_with(|| RefreshFinishedMetrics::new(table_id, status));
575        finished.count.inc();
576        finished.duration.set(duration_secs);
577    }
578
579    fn job_metrics(&mut self, table_id: TableId) -> &mut RefreshJobMetrics {
580        self.metrics
581            .entry(table_id)
582            .or_insert_with(|| RefreshJobMetrics::new(table_id))
583    }
584}
585
586fn cron_interval(job: &refresh_job::Model) -> Option<ChronoDuration> {
587    job.trigger_interval_secs
588        .filter(|secs| *secs > 0)
589        .map(ChronoDuration::seconds)
590}
591
592/// `created_at_millis` stands in for the last trigger time of a job that never ran.
593fn cron_due(job: &refresh_job::Model, created_at_millis: Option<i64>) -> bool {
594    let Some(interval) = cron_interval(job) else {
595        return false;
596    };
597    let Some(last_run) = job.last_trigger_time.or(created_at_millis) else {
598        return false;
599    };
600    Utc::now()
601        .naive_utc()
602        .signed_duration_since(millis_to_datetime(last_run))
603        >= interval
604}
605
606/// Truncated to the millisecond precision of the persisted trigger time.
607fn now_millis() -> NaiveDateTime {
608    millis_to_datetime(Utc::now().timestamp_millis())
609}
610
611/// Wall-clock length of a cycle; falls back to the trigger time when the tracker did not survive a
612/// meta restart.
613fn cycle_duration_secs(tracker: Option<&CycleTracker>, trigger_time: NaiveDateTime) -> u64 {
614    tracker.map_or_else(
615        || u64::try_from((Utc::now().naive_utc() - trigger_time).num_seconds()).unwrap_or(0),
616        |tracker| tracker.start_time.elapsed().as_secs(),
617    )
618}
619
620fn millis_to_datetime(millis: i64) -> NaiveDateTime {
621    DateTime::from_timestamp_millis(millis)
622        .expect("valid timestamp")
623        .naive_utc()
624}
625
626struct RefreshJobMetrics {
627    cron_trigger_count: LabelGuardedIntCounter,
628    cron_miss_count: LabelGuardedIntCounter,
629    finished: HashMap<String, RefreshFinishedMetrics>,
630}
631
632impl RefreshJobMetrics {
633    fn new(table_id: TableId) -> Self {
634        let table_id = table_id.to_string();
635        Self {
636            cron_trigger_count: GLOBAL_META_METRICS
637                .refresh_cron_job_trigger_cnt
638                .with_guarded_label_values(&[&table_id]),
639            cron_miss_count: GLOBAL_META_METRICS
640                .refresh_cron_job_miss_cnt
641                .with_guarded_label_values(&[&table_id]),
642            finished: HashMap::new(),
643        }
644    }
645}
646
647struct RefreshFinishedMetrics {
648    count: LabelGuardedIntCounter,
649    duration: LabelGuardedUintGauge,
650}
651
652impl RefreshFinishedMetrics {
653    fn new(table_id: TableId, status: &str) -> Self {
654        let table_id = table_id.to_string();
655        Self {
656            count: GLOBAL_META_METRICS
657                .refresh_job_finish_cnt
658                .with_guarded_label_values(&[&table_id, status]),
659            duration: GLOBAL_META_METRICS
660                .refresh_job_duration
661                .with_guarded_label_values(&[&table_id, status]),
662        }
663    }
664}
665
666#[derive(Debug)]
667struct CycleTracker {
668    database_id: DatabaseId,
669    associated_source_id: SourceId,
670    staging_table_id: TableId,
671    trigger_time: NaiveDateTime,
672    list: StageProgress,
673    fetch: StageProgress,
674    mview: StageProgress,
675    start_time: Instant,
676}
677
678impl CycleTracker {
679    fn new(
680        database_id: DatabaseId,
681        associated_source_id: SourceId,
682        staging_table_id: TableId,
683        trigger_time: NaiveDateTime,
684        actors: RefreshCycleActors,
685    ) -> Self {
686        Self {
687            database_id,
688            associated_source_id,
689            staging_table_id,
690            trigger_time,
691            list: StageProgress::new(actors.list),
692            fetch: StageProgress::new(actors.fetch),
693            mview: StageProgress::new(actors.mview),
694            start_time: Instant::now(),
695        }
696    }
697
698    fn stage_mut(&mut self, stage: RefreshStage) -> &mut StageProgress {
699        match stage {
700            RefreshStage::List => &mut self.list,
701            RefreshStage::Fetch => &mut self.fetch,
702            RefreshStage::Mview => &mut self.mview,
703        }
704    }
705}
706
707#[derive(Debug)]
708struct StageProgress {
709    expected: HashSet<ActorId>,
710    finished: HashSet<ActorId>,
711}
712
713impl StageProgress {
714    fn new(expected: HashSet<ActorId>) -> Self {
715        Self {
716            expected,
717            finished: HashSet::new(),
718        }
719    }
720
721    /// True once every expected actor reported; an unexpected reporter is an error.
722    fn report(&mut self, stage: RefreshStage, actors: &HashSet<ActorId>) -> MetaResult<bool> {
723        self.finished.extend(actors);
724        if self.finished.len() < self.expected.len() {
725            return Ok(false);
726        }
727        if self.finished == self.expected {
728            Ok(true)
729        } else {
730            Err(anyhow!(
731                "{stage:?} finished actors mismatch: expected: {:?}, actual: {:?}",
732                self.expected,
733                self.finished
734            )
735            .into())
736        }
737    }
738}