Skip to main content

servers/batcher/logical_table/
pending_worker.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::collections::HashMap;
16use std::sync::Arc;
17use std::time::Duration;
18
19use catalog::CatalogManagerRef;
20use common_batcher::flush_limiter::FlushLimiter;
21use common_batcher::flush_policy::FlushTrigger;
22use common_batcher::flush_policy::timing::TimingFlushPolicy;
23use common_batcher::pending_worker::PendingWorker as PendingCore;
24use common_batcher::worker_registry::WorkerRegistry;
25use common_query::WriteProgress;
26use common_telemetry::debug;
27use operator::write_dispatcher::WriteDispatcherRef;
28use partition::manager::PartitionRuleManagerRef;
29use session::context::QueryContextRef;
30use table::metadata::TableId;
31use tokio::sync::{OwnedSemaphorePermit, broadcast, mpsc, oneshot};
32
33use crate::batcher::flow_notifier::FlowNotifier;
34use crate::batcher::logical_table::BatchKey;
35use crate::batcher::logical_table::batch::{self, Batch};
36use crate::batcher::logical_table::batch_convert::{RecordBatchWithTsIdx, TableBatch};
37use crate::metrics::{PENDING_BATCHES, PENDING_ROWS, PENDING_WORKERS};
38
39pub(in crate::batcher::logical_table) struct PendingBatch {
40    inputs: usize,
41    pub(in crate::batcher::logical_table) tables: HashMap<TableId, TableBatch>,
42    pub(in crate::batcher::logical_table) db_string: String,
43    pub(in crate::batcher::logical_table) ctx: QueryContextRef,
44}
45
46pub(in crate::batcher::logical_table) struct FlushWaiter {
47    pub(in crate::batcher::logical_table) _completion:
48        Option<session::write_progress::PendingWrite>,
49    pub(in crate::batcher::logical_table) response_tx: oneshot::Sender<WriteProgress>,
50    pub(in crate::batcher::logical_table) _permit: Arc<OwnedSemaphorePermit>,
51}
52
53#[derive(Clone)]
54pub(in crate::batcher::logical_table) struct PendingWorker {
55    pub(in crate::batcher::logical_table) tx: mpsc::Sender<WorkerCommand>,
56}
57
58/// Commands accepted by a logical-table batching worker.
59pub(crate) enum WorkerCommand {
60    Submit {
61        _completion: Option<session::write_progress::PendingWrite>,
62        table_batches: Vec<(String, u32, RecordBatchWithTsIdx)>,
63        total_rows: usize,
64        ctx: QueryContextRef,
65        response_tx: oneshot::Sender<WriteProgress>,
66        _permit: Arc<OwnedSemaphorePermit>,
67    },
68    #[cfg(test)]
69    Ack { ack_tx: oneshot::Sender<()> },
70}
71
72impl PendingBatch {
73    pub(in crate::batcher::logical_table) fn new(ctx: QueryContextRef) -> Self {
74        let db_string = ctx.get_db_string();
75        Self {
76            inputs: 0,
77            tables: HashMap::new(),
78            db_string,
79            ctx,
80        }
81    }
82
83    pub(in crate::batcher::logical_table) fn add_table_batch(
84        &mut self,
85        table_name: String,
86        table_id: TableId,
87        record_batch: RecordBatchWithTsIdx,
88    ) {
89        let entry = self.tables.entry(table_id).or_insert_with(|| TableBatch {
90            table_name,
91            table_id,
92            batches: Vec::new(),
93            row_count: 0,
94        });
95        entry.row_count += record_batch.batch.num_rows();
96        entry.batches.push(record_batch);
97    }
98}
99
100/// Starts a worker that accumulates logical rows and dispatches physical flushes.
101#[allow(clippy::too_many_arguments)]
102pub(crate) fn start_worker(
103    key: BatchKey,
104    worker_tx: mpsc::Sender<WorkerCommand>,
105    workers: Arc<WorkerRegistry<BatchKey, WorkerCommand>>,
106    mut rx: mpsc::Receiver<WorkerCommand>,
107    shutdown: broadcast::Sender<()>,
108    partition_manager: PartitionRuleManagerRef,
109    write_dispatcher: WriteDispatcherRef,
110    catalog_manager: CatalogManagerRef,
111    flow_notification_tx: FlowNotifier,
112    worker_idle_timeout: Duration,
113    flush_policy: TimingFlushPolicy,
114    flush_limiter: FlushLimiter,
115) {
116    tokio::spawn(async move {
117        // The business batch and pending flush are populated and drained together.
118        let mut batch = None;
119        let mut pending_flush = PendingCore::new(flush_policy);
120        let mut shutdown_rx = shutdown.subscribe();
121        let idle_deadline = tokio::time::Instant::now() + worker_idle_timeout;
122        let idle_timer = tokio::time::sleep_until(idle_deadline);
123        tokio::pin!(idle_timer);
124
125        loop {
126            tokio::select! {
127                cmd = rx.recv() => {
128                    match cmd {
129                        Some(WorkerCommand::Submit { table_batches, total_rows, ctx, response_tx, _permit, _completion }) => {
130                            let submitted_at = tokio::time::Instant::now();
131                            idle_timer.as_mut().reset(submitted_at + worker_idle_timeout);
132
133                            pending_flush.submit(
134                                FlushWaiter { response_tx, _permit, _completion },
135                                total_rows,
136                            );
137                            let progress = ctx.write_progress().cloned();
138                            let pending_batch = batch.get_or_insert_with(||{
139                                PENDING_BATCHES.inc();
140                                PendingBatch::new(ctx)
141                            });
142
143                            for (table_name, table_id, mut record_batch) in table_batches {
144                                record_batch.progress = progress.clone();
145                                record_batch.owner = pending_batch.inputs;
146                                pending_batch.add_table_batch(table_name, table_id, record_batch);
147                            }
148
149                            pending_batch.inputs += 1;
150                            PENDING_ROWS.add(total_rows as i64);
151
152                            if let Some(flush) = drain_batch(&mut batch, &mut pending_flush, Some(FlushTrigger::Submission)) {
153                                batch::spawn_flush(
154                                    flush,
155                                    partition_manager.clone(),
156                                    write_dispatcher.clone(),
157                                    catalog_manager.clone(),
158                                    flow_notification_tx.clone(),
159                                    flush_limiter.clone(),
160                                ).await;
161                            }
162                        }
163                        None => {
164                            if let Some(flush) = drain_batch(&mut batch, &mut pending_flush, None) {
165                                batch::flush_batch_with_managers(
166                                    flush,
167                                    partition_manager.clone(),
168                                    write_dispatcher.clone(),
169                                    catalog_manager.clone(),
170                                    flow_notification_tx.clone(),
171                                ).await;
172                            }
173                            break;
174                        }
175                        #[cfg(test)]
176                        Some(WorkerCommand::Ack { ack_tx }) => {
177                            let _ = ack_tx.send(());
178                        }
179                    }
180                }
181                _ = &mut idle_timer => {
182                    if !should_close_worker_on_idle_timeout(
183                        pending_flush.total_rows(),
184                        rx.len(),
185                    ) {
186                        idle_timer
187                            .as_mut()
188                            .reset(tokio::time::Instant::now() + worker_idle_timeout);
189                        continue;
190                    }
191
192                    debug!(
193                        "Closing idle pending rows worker due to timeout: catalog={}, schema={}, physical_table={}",
194                        key.catalog,
195                        key.schema,
196                        key.physical_table
197                    );
198                    break;
199                }
200                _ = pending_flush.wait_flush() => {
201                    if let Some(flush) = drain_batch(&mut batch, &mut pending_flush, Some(FlushTrigger::Deadline)) {
202                        batch::spawn_flush(
203                            flush,
204                            partition_manager.clone(),
205                            write_dispatcher.clone(),
206                            catalog_manager.clone(),
207                            flow_notification_tx.clone(),
208                            flush_limiter.clone(),
209                        ).await;
210                    }
211                }
212                _ = shutdown_rx.recv() => {
213                    if let Some(flush) = drain_batch(&mut batch, &mut pending_flush, None) {
214                        batch::flush_batch_with_managers(
215                            flush,
216                            partition_manager.clone(),
217                            write_dispatcher.clone(),
218                            catalog_manager.clone(),
219                            flow_notification_tx.clone(),
220                        ).await;
221                    }
222                    break;
223                }
224            }
225        }
226
227        remove_worker_if_same_channel(workers.as_ref(), &key, &worker_tx).await;
228    });
229}
230
231pub(in crate::batcher::logical_table) async fn remove_worker_if_same_channel(
232    workers: &WorkerRegistry<BatchKey, WorkerCommand>,
233    key: &BatchKey,
234    worker_tx: &mpsc::Sender<WorkerCommand>,
235) -> bool {
236    if workers.remove_if_same(key, worker_tx).await {
237        PENDING_WORKERS.set(workers.len().await as i64);
238        true
239    } else {
240        false
241    }
242}
243
244pub(in crate::batcher::logical_table) fn should_close_worker_on_idle_timeout(
245    total_row_count: usize,
246    queued_requests: usize,
247) -> bool {
248    total_row_count == 0 && queued_requests == 0
249}
250
251/// Transfers the ready batch, or drains unconditionally when no trigger is given.
252/// Execution and flush permits remain owned by the caller.
253pub(in crate::batcher::logical_table) fn drain_batch(
254    batch: &mut Option<PendingBatch>,
255    pending_flush: &mut PendingCore<FlushWaiter, TimingFlushPolicy>,
256    trigger: Option<FlushTrigger>,
257) -> Option<Batch> {
258    let total_row_count = pending_flush.total_rows();
259    let waiters = match trigger {
260        Some(trigger) => pending_flush.take_ready(trigger)?,
261        None => pending_flush.take_pending()?,
262    };
263    let batch = batch.take()?;
264
265    if total_row_count == 0 {
266        return None;
267    }
268
269    let table_batches = batch.tables.into_values().collect();
270
271    PENDING_ROWS.sub(total_row_count as i64);
272    PENDING_BATCHES.dec();
273
274    Some(Batch {
275        table_batches,
276        total_row_count,
277        db_string: batch.db_string,
278        ctx: batch.ctx,
279        waiters,
280    })
281}
282
283#[cfg(test)]
284mod tests {
285    use common_query::WriteProgress;
286    use std::collections::HashMap;
287    use std::sync::Arc;
288    use std::time::Duration;
289
290    use catalog::memory::MemoryCatalogManager;
291    use common_batcher::flush_limiter::FlushLimiter;
292    use common_batcher::flush_policy::FlushTrigger;
293    use common_batcher::flush_policy::timing::TimingFlushPolicy;
294    use common_batcher::pending_worker::PendingWorker as PendingCore;
295    use common_batcher::worker_registry::WorkerRegistry;
296    use common_meta::cache::new_table_route_cache;
297    use common_meta::kv_backend::memory::MemoryKvBackend;
298    use common_meta::node_manager::NodeManagerRef;
299    use moka::future::CacheBuilder;
300    use operator::write_dispatcher::WriteDispatcher;
301    use partition::cache::new_partition_info_cache;
302    use partition::manager::PartitionRuleManager;
303    use tokio::sync::{Semaphore, broadcast, mpsc, oneshot};
304    use tokio::time::advance;
305
306    use crate::batcher::flow_notifier::FlowNotifier;
307    use crate::batcher::logical_table::BatchKey;
308    use crate::batcher::logical_table::batch_convert::TableBatch;
309    use crate::batcher::logical_table::pending_worker::{
310        FlushWaiter, PendingBatch, WorkerCommand, drain_batch, remove_worker_if_same_channel,
311        should_close_worker_on_idle_timeout, start_worker,
312    };
313    use crate::batcher::logical_table::test_util::{
314        ConcurrentMockNodeManager, mock_aligned_tag_batch,
315    };
316    use crate::metrics::FLOW_NOTIFICATION_DROPPED;
317
318    #[tokio::test]
319    async fn test_drain_batch_takes_initialized_pending_batch_from_option() {
320        let ctx = session::context::QueryContext::arc();
321        let (response_tx, _response_rx) = oneshot::channel();
322        let permit = Arc::new(Semaphore::new(1)).try_acquire_owned().unwrap();
323        let mut pending_flush =
324            PendingCore::new(TimingFlushPolicy::try_new(Duration::from_secs(10), 1).unwrap());
325        pending_flush.submit(
326            FlushWaiter {
327                _completion: None,
328                response_tx,
329                _permit: Arc::new(permit),
330            },
331            1,
332        );
333        let mut batch = Some(PendingBatch {
334            inputs: 1,
335            tables: HashMap::from([(
336                42,
337                TableBatch {
338                    table_name: "cpu".to_string(),
339                    table_id: 42,
340                    batches: vec![mock_aligned_tag_batch("tag1", "host-1", 1000, 1.0)],
341                    row_count: 1,
342                },
343            )]),
344            db_string: ctx.get_db_string(),
345            ctx: ctx.clone(),
346        });
347
348        let flush = drain_batch(
349            &mut batch,
350            &mut pending_flush,
351            Some(FlushTrigger::Submission),
352        )
353        .unwrap();
354
355        assert!(batch.is_none());
356        assert!(pending_flush.is_empty());
357        assert_eq!(0, pending_flush.total_rows());
358        assert_eq!(1, flush.waiters.len());
359        assert_eq!(1, flush.total_row_count);
360        assert_eq!(1, flush.table_batches.len());
361        assert_eq!(ctx.get_db_string(), flush.db_string);
362        assert_eq!(ctx.current_catalog(), flush.ctx.current_catalog());
363    }
364
365    #[tokio::test]
366    async fn test_drain_batch_preserves_unready_state_and_clears_zero_rows() {
367        for total_rows in [0, 1] {
368            let mut pending_flush =
369                PendingCore::new(TimingFlushPolicy::try_new(Duration::from_secs(10), 2).unwrap());
370            let mut batch = Some(PendingBatch::new(session::context::QueryContext::arc()));
371            let semaphore = Arc::new(Semaphore::new(1));
372            let (response_tx, mut response_rx) = oneshot::channel();
373            pending_flush.submit(
374                FlushWaiter {
375                    _completion: None,
376                    response_tx,
377                    _permit: Arc::new(semaphore.clone().acquire_owned().await.unwrap()),
378                },
379                total_rows,
380            );
381            assert!(
382                drain_batch(
383                    &mut batch,
384                    &mut pending_flush,
385                    Some(FlushTrigger::Submission)
386                )
387                .is_none()
388            );
389            assert!(batch.is_some());
390            assert!(!pending_flush.is_empty());
391            assert_eq!(total_rows, pending_flush.total_rows());
392            assert_eq!(0, semaphore.available_permits());
393
394            let drained = drain_batch(&mut batch, &mut pending_flush, None);
395            assert!(batch.is_none());
396            assert!(pending_flush.is_empty());
397            assert_eq!(0, pending_flush.total_rows());
398            if total_rows == 0 {
399                assert!(drained.is_none());
400                assert_eq!(1, semaphore.available_permits());
401                assert!(matches!(
402                    response_rx.try_recv(),
403                    Err(oneshot::error::TryRecvError::Closed)
404                ));
405            } else {
406                let drained = drained.unwrap();
407                assert_eq!(1, drained.total_row_count);
408                assert_eq!(1, drained.waiters.len());
409                assert_eq!(0, semaphore.available_permits());
410                drop(drained);
411                assert_eq!(1, semaphore.available_permits());
412            }
413        }
414    }
415
416    #[test]
417    fn test_pending_batch_keeps_same_name_batches_with_distinct_table_ids() {
418        let ctx = session::context::QueryContext::arc();
419        let mut pending_batch = PendingBatch::new(ctx);
420
421        pending_batch.add_table_batch(
422            "cpu".to_string(),
423            42,
424            mock_aligned_tag_batch("tag1", "host-1", 1000, 1.0),
425        );
426        pending_batch.add_table_batch(
427            "cpu".to_string(),
428            43,
429            mock_aligned_tag_batch("tag1", "host-1", 2000, 2.0),
430        );
431
432        assert_eq!(2, pending_batch.tables.len());
433        assert_eq!(42, pending_batch.tables[&42].table_id);
434        assert_eq!(43, pending_batch.tables[&43].table_id);
435        assert_eq!("cpu", pending_batch.tables[&42].table_name);
436        assert_eq!("cpu", pending_batch.tables[&43].table_name);
437    }
438
439    #[tokio::test]
440    async fn test_remove_worker_if_same_channel_removes_matching_entry() {
441        let workers = WorkerRegistry::new();
442        let key = BatchKey {
443            catalog: "greptime".to_string(),
444            schema: "public".to_string(),
445            physical_table: "phy".to_string(),
446            skip_wal: false,
447        };
448
449        let (tx, _rx) = mpsc::channel::<WorkerCommand>(1);
450        workers.get_or_insert_with(key.clone(), || tx.clone()).await;
451
452        assert!(remove_worker_if_same_channel(&workers, &key, &tx).await);
453        assert!(workers.is_empty().await);
454    }
455
456    #[tokio::test]
457    async fn test_remove_worker_if_same_channel_keeps_newer_entry() {
458        let workers = WorkerRegistry::new();
459        let key = BatchKey {
460            catalog: "greptime".to_string(),
461            schema: "public".to_string(),
462            physical_table: "phy".to_string(),
463            skip_wal: false,
464        };
465
466        let (stale_tx, _stale_rx) = mpsc::channel::<WorkerCommand>(1);
467        let (fresh_tx, _fresh_rx) = mpsc::channel::<WorkerCommand>(1);
468        workers
469            .get_or_insert_with(key.clone(), || fresh_tx.clone())
470            .await;
471
472        assert!(!remove_worker_if_same_channel(&workers, &key, &stale_tx).await);
473        assert!(workers.get(&key).await.is_some());
474        assert!(workers.get(&key).await.unwrap().same_channel(&fresh_tx));
475    }
476
477    #[test]
478    fn test_worker_idle_timeout_close_decision() {
479        assert!(should_close_worker_on_idle_timeout(0, 0));
480        assert!(!should_close_worker_on_idle_timeout(1, 0));
481        assert!(!should_close_worker_on_idle_timeout(0, 1));
482    }
483
484    #[tokio::test(start_paused = true)]
485    async fn test_worker_preserves_first_deadline_and_inline_shutdown() {
486        let flush_interval = Duration::from_secs(10);
487        let worker_idle_timeout = Duration::from_secs(30);
488        let key = BatchKey {
489            catalog: "greptime".to_string(),
490            schema: "public".to_string(),
491            physical_table: "phy".to_string(),
492            skip_wal: false,
493        };
494        let workers = Arc::new(WorkerRegistry::new());
495        let (worker_tx, worker_rx) = mpsc::channel(1);
496        workers
497            .get_or_insert_with(key.clone(), || worker_tx.clone())
498            .await;
499
500        let backend = Arc::new(MemoryKvBackend::default());
501        let table_route_cache = Arc::new(new_table_route_cache(
502            "pending-rows-flush-deadline-routes".to_string(),
503            CacheBuilder::new(1).build(),
504            backend.clone(),
505        ));
506        let partition_info_cache = Arc::new(new_partition_info_cache(
507            "pending-rows-flush-deadline-partitions".to_string(),
508            CacheBuilder::new(1).build(),
509            table_route_cache.clone(),
510        ));
511        let partition_manager = Arc::new(PartitionRuleManager::new(
512            backend,
513            table_route_cache,
514            partition_info_cache,
515        ));
516        let node_manager: NodeManagerRef = Arc::new(ConcurrentMockNodeManager {
517            datanodes: Arc::new(HashMap::new()),
518        });
519        let write_dispatcher = Arc::new(WriteDispatcher::new(
520            node_manager,
521            partition_manager.clone(),
522        ));
523        let catalog_manager = MemoryCatalogManager::with_default_setup();
524        let (flow_notification_tx, _flow_notification_rx) =
525            FlowNotifier::try_new(1, FLOW_NOTIFICATION_DROPPED.clone()).unwrap();
526        let (shutdown, _) = broadcast::channel(1);
527
528        let flush_limiter = FlushLimiter::try_new(1).unwrap();
529        start_worker(
530            key.clone(),
531            worker_tx.clone(),
532            workers.clone(),
533            worker_rx,
534            shutdown.clone(),
535            partition_manager,
536            write_dispatcher,
537            catalog_manager,
538            flow_notification_tx,
539            worker_idle_timeout,
540            TimingFlushPolicy::try_new(flush_interval, 3).unwrap(),
541            flush_limiter.clone(),
542        );
543
544        // Start the worker, then size-flush a batch halfway to the first
545        // worker-aligned interval boundary. This arms the reusable timer and
546        // drains the batch before that deadline is reached.
547        tokio::task::yield_now().await;
548        advance(flush_interval / 2).await;
549        let size_flush_rx = submit_mock_worker_batch(&worker_tx, 3, 1000).await;
550        let size_flush_result =
551            receive_mock_flush_result(size_flush_rx, "row threshold did not flush the first batch")
552                .await;
553        assert_missing_physical_table(size_flush_result);
554
555        // Submit a low-volume batch before the first batch's old timer would
556        // expire. It must receive a fresh full interval.
557        advance(flush_interval / 5).await;
558        let mut timed_flush_rx = submit_mock_worker_batch(&worker_tx, 1, 2000).await;
559
560        let first_submission = tokio::time::Instant::now();
561        advance(flush_interval / 2).await;
562        let later_flush_rx = submit_mock_worker_batch(&worker_tx, 1, 3000).await;
563        advance(flush_interval / 2 - Duration::from_millis(1)).await;
564        for _ in 0..10 {
565            tokio::task::yield_now().await;
566        }
567        assert!(matches!(
568            timed_flush_rx.try_recv(),
569            Err(oneshot::error::TryRecvError::Empty)
570        ));
571
572        advance(Duration::from_millis(1)).await;
573        let timed_flush_result = receive_mock_flush_result(
574            timed_flush_rx,
575            "batch was not flushed one interval after its creation",
576        )
577        .await;
578        assert_missing_physical_table(timed_flush_result);
579
580        assert_eq!(
581            first_submission + flush_interval,
582            tokio::time::Instant::now()
583        );
584        assert_missing_physical_table(
585            receive_mock_flush_result(
586                later_flush_rx,
587                "later submission was not included in the timed flush",
588            )
589            .await,
590        );
591
592        // Shutdown retains the existing inline path even with no flush permits.
593        let _held_permit = flush_limiter.acquire().await.unwrap();
594        let shutdown_flush_rx = submit_mock_worker_batch(&worker_tx, 1, 4000).await;
595        let shutdown_at = tokio::time::Instant::now();
596        let _ = shutdown.send(());
597        assert_missing_physical_table(
598            receive_mock_flush_result(
599                shutdown_flush_rx,
600                "shutdown incorrectly waited for a flush permit",
601            )
602            .await,
603        );
604        assert_eq!(shutdown_at, tokio::time::Instant::now());
605        for _ in 0..10 {
606            if workers.is_empty().await {
607                break;
608            }
609            tokio::task::yield_now().await;
610        }
611        assert!(
612            workers.is_empty().await,
613            "worker did not exit after shutdown"
614        );
615    }
616
617    const WORKER_TEST_TIMEOUT: Duration = Duration::from_secs(30);
618
619    async fn submit_mock_worker_batch(
620        worker_tx: &mpsc::Sender<WorkerCommand>,
621        total_rows: usize,
622        timestamp: i64,
623    ) -> oneshot::Receiver<WriteProgress> {
624        let (response_tx, response_rx) = oneshot::channel();
625        let permit = Arc::new(Semaphore::new(1)).acquire_owned().await.unwrap();
626        worker_tx
627            .send(WorkerCommand::Submit {
628                _completion: None,
629                table_batches: vec![(
630                    "cpu".to_string(),
631                    42,
632                    mock_aligned_tag_batch("tag1", "host-1", timestamp, 1.0),
633                )],
634                total_rows,
635                ctx: session::context::QueryContext::arc(),
636                response_tx,
637                _permit: Arc::new(permit),
638            })
639            .await
640            .unwrap();
641
642        // The channel is FIFO, so the ack proves the worker has dequeued and
643        // processed the submission (anchoring the flush deadline) before the
644        // caller advances virtual time.
645        let (ack_tx, ack_rx) = oneshot::channel();
646        worker_tx.send(WorkerCommand::Ack { ack_tx }).await.unwrap();
647        ack_rx
648            .await
649            .expect("worker exited before acking the submitted batch");
650
651        response_rx
652    }
653
654    async fn receive_mock_flush_result(
655        response_rx: oneshot::Receiver<WriteProgress>,
656        context: &str,
657    ) -> WriteProgress {
658        // Under paused time the timeout auto-advances the clock and fires
659        // deterministically if the flush never completes.
660        tokio::time::timeout(WORKER_TEST_TIMEOUT, response_rx)
661            .await
662            .unwrap_or_else(|_| panic!("{context}"))
663            .expect("flush result channel closed without a result")
664    }
665
666    fn assert_missing_physical_table(result: WriteProgress) {
667        let err = result
668            .error
669            .expect("the empty catalog should make the flush fail");
670        assert!(
671            err.to_string().contains("not found during pending flush"),
672            "unexpected flush error: {err}"
673        );
674    }
675}