Skip to main content

meta_srv/procedure/drain_datanode/
dispatch.rs

1// Copyright 2023 Greptime Team
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::any::Any;
16
17use common_procedure::{Context as ProcedureContext, Status, SubprocedureRecovery};
18use common_telemetry::{error, info, warn};
19use serde::{Deserialize, Serialize};
20use store_api::storage::RegionId;
21
22use crate::error::{self, Result};
23use crate::metrics::{METRIC_META_REGION_MIGRATION_DATANODES, METRIC_META_REGION_MIGRATION_FAIL};
24use crate::procedure::drain_datanode::check_drained::CheckDrained;
25use crate::procedure::drain_datanode::utils;
26use crate::procedure::drain_datanode::{Context, DrainDatanodeStrategy, State, SubprocedureMeta};
27
28/// Migrates the leader regions off the drained datanode in batches.
29///
30/// The state first settles the previous batch, then dispatches the next one. Sub-procedures
31/// are tracked in the volatile context, and the suspension is not persisted, so a metasrv
32/// restart rebuilds the plan by rescanning the datanode. Recovered migrations run
33/// independently and are excluded by the migration tracker; regions that already left
34/// the datanode are skipped by the route check in
35/// [`Context::build_migration_procedure`].
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub(crate) struct Dispatch;
38
39#[async_trait::async_trait]
40#[typetag::serde]
41impl State for Dispatch {
42    async fn next(
43        &mut self,
44        ctx: &mut Context,
45        procedure_ctx: &ProcedureContext,
46    ) -> Result<(Box<dyn State>, Status)> {
47        let node_id = ctx.persistent_ctx.node_id;
48
49        // Collect the previous batch's results before dispatching more migrations.
50        if !ctx.volatile_ctx.inflight_subprocedures.is_empty() {
51            ctx.wait_for_inflight_subprocedures(procedure_ctx, ctx.persistent_ctx.strategy)
52                .await?;
53            return Ok((Box::new(Dispatch), Status::executing(false)));
54        }
55
56        utils::ensure_drain_deadline(ctx)?;
57
58        let parallelism = ctx.persistent_ctx.parallelism;
59        let regions: Vec<RegionId> = ctx
60            .volatile_ctx
61            .leader_regions()
62            .iter()
63            .take(parallelism)
64            .copied()
65            .collect();
66        if regions.is_empty() {
67            return Ok((Box::new(CheckDrained), Status::executing(false)));
68        }
69
70        let mut children = Vec::with_capacity(regions.len());
71        let mut inflight = Vec::with_capacity(regions.len());
72        let mut targets = Vec::with_capacity(regions.len());
73        for region_id in regions {
74            match ctx
75                .build_migration_procedure(procedure_ctx, region_id)
76                .await
77            {
78                Ok(Some((child, target_node_id))) => {
79                    info!(
80                        "Dispatching migration of region {} off datanode {}, procedure id: {}",
81                        region_id, node_id, child.id
82                    );
83                    inflight.push(SubprocedureMeta {
84                        region_id,
85                        procedure_id: child.id,
86                    });
87                    targets.push(target_node_id);
88                    children.push(child);
89                }
90                Ok(None) => {
91                    info!(
92                        "Skipping region {}, it is no longer led by datanode {}",
93                        region_id, node_id
94                    );
95                    ctx.forget_leader_region(region_id);
96                }
97                Err(error::Error::MigrationRunning { .. }) => {
98                    // Another procedure is migrating the region already, for example a manual
99                    // migration submitted before this batch. Ignore it for this round; the
100                    // rescan adds it back when it is still led by the datanode.
101                    warn!(
102                        "A migration of region {} off datanode {} is already running, \
103                         skipping the region",
104                        region_id, node_id
105                    );
106                    ctx.forget_leader_region(region_id);
107                }
108                Err(
109                    e @ (error::Error::TableRouteNotFound { .. }
110                    | error::Error::TableInfoNotFound { .. }),
111                ) => {
112                    warn!(
113                        e;
114                        "Skipping region {} while draining datanode {}: table metadata is missing",
115                        region_id, node_id
116                    );
117                    ctx.forget_leader_region(region_id);
118                }
119                Err(e) => {
120                    // Another migration may finish between target selection and the
121                    // migration manager's route check, releasing its tracker guard.
122                    if matches!(
123                        e,
124                        error::Error::RegionMigrated { .. }
125                            | error::Error::LeaderPeerChanged { .. }
126                    ) && !utils::leads_region(&ctx.table_metadata_manager, node_id, region_id)
127                        .await?
128                    {
129                        info!(
130                            "Skipping region {}, it is no longer led by datanode {}",
131                            region_id, node_id
132                        );
133                        ctx.forget_leader_region(region_id);
134                        continue;
135                    }
136
137                    match ctx.persistent_ctx.strategy {
138                        // Nothing is committed yet, so the batch can be retried as a whole.
139                        DrainDatanodeStrategy::FailFast => return Err(e),
140                        DrainDatanodeStrategy::TryAll => {
141                            METRIC_META_REGION_MIGRATION_FAIL.inc();
142                            error!(
143                                e;
144                                "Gave up draining region {} off datanode {}",
145                                region_id,
146                                node_id
147                            );
148                            ctx.forget_leader_region(region_id);
149                            ctx.volatile_ctx.failed_regions.push(region_id);
150                        }
151                    }
152                }
153            }
154        }
155        ctx.volatile_ctx.inflight_subprocedures = inflight;
156
157        if children.is_empty() {
158            // The whole batch was handled without a sub-procedure, so keep draining the
159            // remaining leaders instead of spending a rescan round on them.
160            return Ok((Box::new(Dispatch), Status::executing(false)));
161        }
162
163        for target_node_id in targets {
164            METRIC_META_REGION_MIGRATION_DATANODES
165                .with_label_values(&["src", &node_id.to_string()])
166                .inc();
167            METRIC_META_REGION_MIGRATION_DATANODES
168                .with_label_values(&["desc", &target_node_id.to_string()])
169                .inc();
170        }
171
172        // Recovered children continue independently. The parent rebuilds its plan and
173        // consults the migration tracker instead of restoring the batch's child links.
174        Ok((
175            Box::new(Dispatch),
176            Status::Suspended {
177                subprocedures: children,
178                recovery: SubprocedureRecovery::Detach,
179                persist: false,
180            },
181        ))
182    }
183
184    fn as_any(&self) -> &dyn Any {
185        self
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use std::collections::HashMap;
192    use std::sync::Arc;
193    use std::time::Duration;
194
195    use crate::procedure::test_util::{procedure_context_with_receivers, procedure_state_receiver};
196    use api::v1::meta::heartbeat_request::NodeWorkloads;
197    use common_meta::cluster::NodeInfo;
198    use common_meta::error::Result as MetaResult;
199    use common_meta::key::table_route::TableRouteKey;
200    use common_meta::key::{MetadataKey, TableMetadataManager, TableMetadataManagerRef};
201    use common_meta::kv_backend::test_util::MockKvBackendBuilder;
202    use common_meta::peer::{Peer, PeerDiscovery, PeerDiscoveryRef};
203    use common_meta::rpc::store::BatchGetResponse;
204    use common_procedure::{ProcedureId, ProcedureState, Status};
205    use store_api::storage::RegionId;
206
207    use crate::error;
208    use crate::key::NodeSchedulingRole;
209    use crate::procedure::drain_datanode::check_drained::CheckDrained;
210    use crate::procedure::drain_datanode::dispatch::Dispatch;
211    use crate::procedure::drain_datanode::drain_datanode_start::DrainDatanodeStart;
212    use crate::procedure::drain_datanode::test_util::{
213        TestEnv, follower_region, leader_region, leader_regions, new_persistent_context,
214        run_until_done, seed_plan,
215    };
216    use crate::procedure::drain_datanode::{DrainDatanodeStrategy, State, SubprocedureMeta};
217    use crate::procedure::region_migration::manager::{
218        RegionMigrationManager, RegionMigrationProcedureTask, RegionMigrationTriggerReason,
219    };
220    use crate::procedure::region_migration::test_util::TestingEnv;
221
222    #[tokio::test]
223    async fn test_drain_datanode_dispatch_transitions_to_check_drained() {
224        let env = TestEnv::new().await;
225        // Only a follower region: there is no leader region to migrate.
226        env.create_table(vec![follower_region(RegionId::new(1024, 1), 2, 1)])
227            .await;
228
229        let mut ctx = env.context(new_persistent_context(
230            Some(2),
231            DrainDatanodeStrategy::FailFast,
232        ));
233        let procedure_ctx = TestingEnv::procedure_context();
234        seed_plan(&mut ctx, vec![]);
235        let (next, status) = Dispatch.next(&mut ctx, &procedure_ctx).await.unwrap();
236
237        assert!(next.as_any().downcast_ref::<CheckDrained>().is_some());
238        assert!(matches!(status, Status::Executing { persist: false, .. }));
239    }
240
241    #[tokio::test]
242    async fn test_drain_datanode_dispatches_migration() {
243        let env = TestEnv::new().await;
244        let region_id = RegionId::new(1024, 1);
245        env.create_table(vec![leader_region(region_id, 1)]).await;
246
247        let mut ctx = env.context(new_persistent_context(
248            Some(2),
249            DrainDatanodeStrategy::FailFast,
250        ));
251        let procedure_ctx = TestingEnv::procedure_context();
252
253        let (mut next, _) = DrainDatanodeStart
254            .next(&mut ctx, &procedure_ctx)
255            .await
256            .unwrap();
257        let status = run_until_done(&mut next, &mut ctx, &procedure_ctx)
258            .await
259            .unwrap();
260
261        let Status::Suspended {
262            subprocedures,
263            recovery,
264            persist,
265        } = status
266        else {
267            panic!("expected the dispatch state to suspend the procedure");
268        };
269        // Recovery resumes children independently and rebuilds the parent's plan.
270        assert!(!persist);
271        assert_eq!(common_procedure::SubprocedureRecovery::Detach, recovery);
272        assert_eq!(1, subprocedures.len());
273        assert_eq!(1, ctx.volatile_ctx.inflight_subprocedures.len());
274        assert_eq!(
275            region_id,
276            ctx.volatile_ctx.inflight_subprocedures[0].region_id
277        );
278        // The region is only removed after its migration is settled.
279        assert_eq!(vec![region_id], leader_regions(&ctx));
280        assert!(next.as_any().downcast_ref::<Dispatch>().is_some());
281    }
282
283    #[tokio::test]
284    async fn test_drain_datanode_dispatch_skips_region_moved_away() {
285        let env = TestEnv::new().await;
286        let region_id = RegionId::new(1024, 1);
287        // The plan lists the region, but the table route says another datanode leads it.
288        env.create_table(vec![leader_region(region_id, 2)]).await;
289
290        let mut ctx = env.context(new_persistent_context(
291            Some(3),
292            DrainDatanodeStrategy::FailFast,
293        ));
294        seed_plan(&mut ctx, vec![region_id]);
295        let procedure_ctx = TestingEnv::procedure_context();
296
297        let (next, status) = Dispatch.next(&mut ctx, &procedure_ctx).await.unwrap();
298
299        assert!(matches!(status, Status::Executing { .. }));
300        assert!(next.as_any().downcast_ref::<Dispatch>().is_some());
301        assert!(leader_regions(&ctx).is_empty());
302        assert!(ctx.volatile_ctx.inflight_subprocedures.is_empty());
303    }
304
305    /// Changes the route after the drain's first read, during target discovery.
306    struct ChangeRouteDuringDiscovery {
307        inner: PeerDiscoveryRef,
308        metadata: TableMetadataManagerRef,
309        region_id: RegionId,
310        leader_id: Option<u64>,
311    }
312
313    #[async_trait::async_trait]
314    impl PeerDiscovery for ChangeRouteDuringDiscovery {
315        async fn active_frontends(&self) -> MetaResult<Vec<NodeInfo>> {
316            self.inner.active_frontends().await
317        }
318
319        async fn active_datanodes(
320            &self,
321            filter: Option<for<'a> fn(&'a NodeWorkloads) -> bool>,
322        ) -> MetaResult<Vec<NodeInfo>> {
323            let storage = self.metadata.table_route_manager().table_route_storage();
324            let table_id = self.region_id.table_id();
325            let current = storage.get_with_raw_bytes(table_id).await?.unwrap();
326            if let Some(leader_id) = self.leader_id {
327                let updated = current.update(vec![leader_region(self.region_id, leader_id)])?;
328                let (txn, _) = storage.build_update_txn(table_id, &current, &updated)?;
329                assert!(self.metadata.kv_backend().txn(txn).await?.succeeded);
330            } else {
331                let table_info = self
332                    .metadata
333                    .table_info_manager()
334                    .get(table_id)
335                    .await?
336                    .unwrap();
337                self.metadata
338                    .delete_table_metadata(
339                        table_id,
340                        &table_info.table_name(),
341                        &current,
342                        &HashMap::new(),
343                        None,
344                    )
345                    .await?;
346            }
347            self.inner.active_datanodes(filter).await
348        }
349
350        async fn active_flownodes(
351            &self,
352            filter: Option<for<'a> fn(&'a NodeWorkloads) -> bool>,
353        ) -> MetaResult<Vec<NodeInfo>> {
354            self.inner.active_flownodes(filter).await
355        }
356    }
357
358    #[tokio::test]
359    async fn test_drop_after_route_read_fail_fast() {
360        assert_drop_after_route_read_is_skipped(DrainDatanodeStrategy::FailFast).await;
361    }
362
363    #[tokio::test]
364    async fn test_drop_after_route_read_try_all() {
365        assert_drop_after_route_read_is_skipped(DrainDatanodeStrategy::TryAll).await;
366    }
367
368    async fn assert_drop_after_route_read_is_skipped(strategy: DrainDatanodeStrategy) {
369        let env = TestEnv::new().await;
370        let region_id = RegionId::new(1024, 1);
371        env.create_table(vec![leader_region(region_id, 1)]).await;
372        let metadata = env.table_metadata_manager.clone();
373        // Only intercept the migration manager's reads. Return its route snapshot
374        // after deleting the table, so its following table-info read finds nothing.
375        let backend = MockKvBackendBuilder::default()
376            // This fixture has no node-address metadata to remap the route.
377            .batch_get_fn(Arc::new(|_| Ok(BatchGetResponse { kvs: vec![] })))
378            .range_async_fn(Arc::new(move |request| {
379                let metadata = metadata.clone();
380                Box::pin(async move {
381                    let response = metadata.kv_backend().range(request.clone()).await?;
382                    if request.key == TableRouteKey::new(region_id.table_id()).to_bytes() {
383                        let route = metadata
384                            .table_route_manager()
385                            .table_route_storage()
386                            .get_with_raw_bytes(region_id.table_id())
387                            .await?
388                            .unwrap();
389                        let info = metadata
390                            .table_info_manager()
391                            .get(region_id.table_id())
392                            .await?
393                            .unwrap();
394                        metadata
395                            .delete_table_metadata(
396                                region_id.table_id(),
397                                &info.table_name(),
398                                &route,
399                                &HashMap::new(),
400                                None,
401                            )
402                            .await?;
403                    }
404                    Ok(response)
405                })
406            }))
407            .build()
408            .unwrap();
409        let factory = env
410            .env
411            .context_factory_with_metadata(Arc::new(TableMetadataManager::new(Arc::new(backend))));
412        let manager = Arc::new(RegionMigrationManager::new(
413            env.procedure_manager().clone(),
414            factory,
415        ));
416        let mut ctx = env.context(new_persistent_context(Some(2), strategy));
417        ctx.region_migration_manager = manager.clone();
418        seed_plan(&mut ctx, vec![region_id]);
419
420        let (next, status) = Dispatch
421            .next(&mut ctx, &TestingEnv::procedure_context())
422            .await
423            .unwrap();
424
425        assert!(next.as_any().is::<Dispatch>());
426        assert!(matches!(status, Status::Executing { persist: false, .. }));
427        assert!(leader_regions(&ctx).is_empty());
428        assert!(ctx.volatile_ctx.failed_regions.is_empty());
429        assert!(ctx.volatile_ctx.inflight_subprocedures.is_empty());
430        assert_eq!(0, ctx.volatile_ctx.migrated);
431        assert!(!manager.tracker().contains(region_id));
432        assert!(
433            env.table_metadata_manager
434                .table_info_manager()
435                .get(region_id.table_id())
436                .await
437                .unwrap()
438                .is_none()
439        );
440    }
441
442    #[tokio::test]
443    async fn test_drain_datanode_dispatch_skips_region_moved_or_dropped_during_discovery() {
444        for strategy in [
445            DrainDatanodeStrategy::FailFast,
446            DrainDatanodeStrategy::TryAll,
447        ] {
448            // Target 2 yields RegionMigrated; leader 3 yields LeaderPeerChanged;
449            // dropping the table yields TableRouteNotFound.
450            for leader_id in [Some(2), Some(3), None] {
451                let env = TestEnv::new().await;
452                let region_id = RegionId::new(1024, 1);
453                env.create_table(vec![leader_region(region_id, 1)]).await;
454                let mut ctx = env.context(new_persistent_context(Some(2), strategy));
455                ctx.selector_ctx.peer_discovery = Arc::new(ChangeRouteDuringDiscovery {
456                    inner: ctx.selector_ctx.peer_discovery.clone(),
457                    metadata: env.table_metadata_manager.clone(),
458                    region_id,
459                    leader_id,
460                });
461                seed_plan(&mut ctx, vec![region_id]);
462
463                let (next, status) = Dispatch
464                    .next(&mut ctx, &TestingEnv::procedure_context())
465                    .await
466                    .unwrap();
467
468                assert!(next.as_any().is::<Dispatch>());
469                assert!(matches!(status, Status::Executing { persist: false, .. }));
470                assert!(leader_regions(&ctx).is_empty());
471                assert!(ctx.volatile_ctx.failed_regions.is_empty());
472                assert!(ctx.volatile_ctx.inflight_subprocedures.is_empty());
473                assert_eq!(0, ctx.volatile_ctx.migrated);
474                assert!(!env.region_migration_manager.tracker().contains(region_id));
475            }
476        }
477    }
478
479    #[tokio::test]
480    async fn test_drain_datanode_skips_region_whose_migration_is_running() {
481        let env = TestEnv::new().await;
482        let region_id = RegionId::new(1024, 1);
483        env.create_table(vec![leader_region(region_id, 1)]).await;
484
485        let mut ctx = env.context(new_persistent_context(
486            Some(2),
487            DrainDatanodeStrategy::FailFast,
488        ));
489        seed_plan(&mut ctx, vec![region_id]);
490        let procedure_ctx = TestingEnv::procedure_context();
491
492        // A manual migration of the same region is already running. Building it keeps the
493        // region in the running set of the migration manager until it is dropped.
494        let _child = env
495            .region_migration_manager
496            .build_subprocedure(
497                common_procedure::ProcedureContext::default(),
498                RegionMigrationProcedureTask::new(
499                    region_id,
500                    Peer::empty(1),
501                    Peer::empty(2),
502                    Duration::from_secs(10),
503                    RegionMigrationTriggerReason::Manual,
504                ),
505            )
506            .await
507            .expect("the manual migration should be buildable");
508
509        let (next, status) = Dispatch.next(&mut ctx, &procedure_ctx).await.unwrap();
510
511        // The region is ignored for this round instead of failing the drain: it leaves the plan
512        // and is neither migrated nor given up. The rescan re-adds it when the datanode still
513        // leads it.
514        assert!(matches!(status, Status::Executing { .. }));
515        assert!(next.as_any().downcast_ref::<Dispatch>().is_some());
516        assert!(leader_regions(&ctx).is_empty());
517        assert!(ctx.volatile_ctx.failed_regions.is_empty());
518        assert!(ctx.volatile_ctx.inflight_subprocedures.is_empty());
519    }
520
521    #[tokio::test]
522    async fn test_drain_datanode_fail_fast_on_unschedulable_target() {
523        let env = TestEnv::new().await;
524        let region_id = RegionId::new(1024, 1);
525        env.create_table(vec![leader_region(region_id, 1)]).await;
526        env.node_scheduling_manager
527            .mark_unschedulable(NodeSchedulingRole::Datanode, 3)
528            .await
529            .unwrap();
530
531        let mut ctx = env.context(new_persistent_context(
532            Some(3),
533            DrainDatanodeStrategy::FailFast,
534        ));
535        let procedure_ctx = TestingEnv::procedure_context();
536
537        let (mut state, _) = DrainDatanodeStart
538            .next(&mut ctx, &procedure_ctx)
539            .await
540            .unwrap();
541        let error = run_until_done(&mut state, &mut ctx, &procedure_ctx)
542            .await
543            .unwrap_err();
544
545        assert!(matches!(error, error::Error::DrainDatanodeAbort { .. }));
546        assert!(!error.is_retryable());
547        assert_eq!(vec![region_id], leader_regions(&ctx));
548    }
549
550    #[tokio::test]
551    async fn test_drain_datanode_waits_for_migrated_batch() {
552        let env = TestEnv::new().await;
553        let region_id = RegionId::new(1024, 1);
554        env.create_table(vec![leader_region(region_id, 1)]).await;
555
556        let mut ctx = env.context(new_persistent_context(
557            Some(2),
558            DrainDatanodeStrategy::FailFast,
559        ));
560        seed_plan(&mut ctx, vec![region_id]);
561        ctx.volatile_ctx.inflight_subprocedures = vec![SubprocedureMeta {
562            region_id,
563            procedure_id: ProcedureId::random(),
564        }];
565        let procedure_ctx = procedure_context_with_receivers(HashMap::from([(
566            ctx.volatile_ctx.inflight_subprocedures[0].procedure_id,
567            procedure_state_receiver(ProcedureState::Done { output: None }),
568        )]));
569
570        let (mut next, status) = Dispatch.next(&mut ctx, &procedure_ctx).await.unwrap();
571
572        assert!(matches!(status, Status::Executing { .. }));
573        assert!(next.as_any().downcast_ref::<Dispatch>().is_some());
574        assert_eq!(1, ctx.volatile_ctx.migrated);
575        assert!(leader_regions(&ctx).is_empty());
576        assert!(ctx.volatile_ctx.inflight_subprocedures.is_empty());
577        // The next step checks the remaining regions.
578        let (next, _) = next.next(&mut ctx, &procedure_ctx).await.unwrap();
579        assert!(next.as_any().downcast_ref::<CheckDrained>().is_some());
580    }
581
582    #[tokio::test]
583    async fn test_drain_datanode_exceeded_deadline() {
584        let env = TestEnv::new().await;
585        let region_id = RegionId::new(1024, 1);
586        env.create_table(vec![leader_region(region_id, 1)]).await;
587
588        let mut persistent_ctx = new_persistent_context(Some(2), DrainDatanodeStrategy::FailFast);
589        persistent_ctx.deadline_ms = 0;
590        let mut ctx = env.context(persistent_ctx);
591        seed_plan(&mut ctx, vec![region_id]);
592        let procedure_ctx = TestingEnv::procedure_context();
593
594        let error = Dispatch.next(&mut ctx, &procedure_ctx).await.unwrap_err();
595
596        assert!(matches!(error, error::Error::ExceededDeadline { .. }));
597        assert!(!error.is_retryable());
598        assert_eq!(vec![region_id], leader_regions(&ctx));
599    }
600
601    #[tokio::test]
602    async fn test_drain_datanode_deadline_does_not_interrupt_batch() {
603        let env = TestEnv::new().await;
604        let region_id = RegionId::new(1024, 1);
605        env.create_table(vec![leader_region(region_id, 1)]).await;
606
607        let mut persistent_ctx = new_persistent_context(Some(2), DrainDatanodeStrategy::FailFast);
608        persistent_ctx.deadline_ms = 0;
609        let mut ctx = env.context(persistent_ctx);
610        seed_plan(&mut ctx, vec![region_id]);
611        ctx.volatile_ctx.inflight_subprocedures = vec![SubprocedureMeta {
612            region_id,
613            procedure_id: ProcedureId::random(),
614        }];
615        let procedure_ctx = procedure_context_with_receivers(HashMap::from([(
616            ctx.volatile_ctx.inflight_subprocedures[0].procedure_id,
617            procedure_state_receiver(ProcedureState::Done { output: None }),
618        )]));
619
620        // The in-flight batch is settled before the deadline is checked.
621        let (_, status) = Dispatch.next(&mut ctx, &procedure_ctx).await.unwrap();
622        assert!(matches!(status, Status::Executing { .. }));
623        assert_eq!(1, ctx.volatile_ctx.migrated);
624
625        // The next step observes the exceeded deadline.
626        let error = Dispatch.next(&mut ctx, &procedure_ctx).await.unwrap_err();
627        assert!(matches!(error, error::Error::ExceededDeadline { .. }));
628    }
629}