Skip to main content

apalis_diesel_postgres/
fetcher.rs

1use std::{
2    collections::VecDeque,
3    marker::PhantomData,
4    pin::Pin,
5    sync::{
6        Arc,
7        atomic::{AtomicUsize, Ordering},
8    },
9    task::{Context, Poll},
10    time::Duration,
11};
12
13use apalis_core::{
14    backend::{
15        TaskStream,
16        codec::Codec,
17        poll_strategy::{PollContext, PollStrategyExt},
18    },
19    task::Task,
20    timer::Delay,
21    worker::context::WorkerContext,
22};
23use futures::{
24    FutureExt, Stream, StreamExt, TryFutureExt,
25    future::{BoxFuture, ready},
26    stream,
27};
28
29use crate::{CompactType, Config, Error, PgContext, PgPool, PgTask, PgTaskId, queries};
30
31/// A fetcher that waits for PostgreSQL NOTIFY events.
32#[derive(Debug, Clone, Default)]
33pub struct PgNotify;
34
35/// Gate `body` behind `register`: emit the registration outcome as the first
36/// stream item (preserving the wire contract that consumers observe it), and
37/// only proceed to drain `body` when registration succeeded. On failure the
38/// body is never polled — fixing the pre-fix shape `once(register).chain(body)`
39/// which emitted the registration error but still ran the body afterwards,
40/// masking the original error under follow-up FK/lock errors.
41///
42/// `flat_map` is called at most once (upstream is a 1-item stream), so an
43/// `Option::take` is sufficient to move the body out of the `FnMut` closure on
44/// its single invocation.
45pub(crate) fn register_then_stream<S>(
46    register: impl Future<Output = Result<Option<PgTask<CompactType>>, Error>> + Send + 'static,
47    body: S,
48) -> TaskStream<PgTask<CompactType>, Error>
49where
50    S: Stream<Item = Result<Option<PgTask<CompactType>>, Error>> + Send + 'static,
51{
52    let mut body_slot = Some(body);
53    stream::once(register)
54        .flat_map(move |res| match res {
55            Ok(none) => {
56                let b = body_slot
57                    .take()
58                    .expect("registration flat_map invoked twice");
59                stream::once(ready(Ok(none))).chain(b).left_stream()
60            }
61            Err(e) => stream::once(ready(Err(e))).right_stream(),
62        })
63        .boxed()
64}
65
66/// Decode a compact task stream into an `Args`-typed task stream by mapping
67/// every yielded row through the configured codec. Shared between the polling
68/// and notify backends so the decode logic exists in exactly one place.
69///
70/// Decode runs *after* the dequeue SQL has already claimed the row as
71/// `Running`, so a decode failure must not just surface the error: it also
72/// fails the claimed row (best-effort) via `fail_undecodable_task`, otherwise
73/// the row would stay `Running` for as long as this worker keeps heartbeating
74/// — unackable (ack needs a decoded task) and invisible to orphan recovery
75/// (which only reclaims rows of stale workers).
76pub(crate) fn decode_task_stream<Args, Decode>(
77    compact: TaskStream<PgTask<CompactType>, Error>,
78    pool: PgPool,
79    worker_id: String,
80) -> TaskStream<PgTask<Args>, Error>
81where
82    Args: Send + 'static,
83    Decode: Codec<Args, Compact = CompactType> + 'static,
84    Decode::Error: std::error::Error + Send + Sync + 'static,
85{
86    compact
87        .then(move |row| {
88            let pool = pool.clone();
89            let worker_id = worker_id.clone();
90            async move {
91                match row {
92                    Ok(Some(task)) => {
93                        // Claim-epoch identity for the release predicate: the
94                        // decode stage runs before the worker increments the
95                        // attempt counter, so `attempt.current()` still holds
96                        // the row's stored value from the claim.
97                        let task_id = task.parts.task_id;
98                        let lock_at = *task.parts.ctx.lock_at();
99                        let attempts = i32::try_from(task.parts.attempt.current());
100                        match task
101                            .try_map(|t| Decode::decode(&t).map_err(|e| Error::Decode(e.into())))
102                        {
103                            Ok(decoded) => Ok(Some(decoded)),
104                            Err(error) => {
105                                // Best-effort: the decode error is the primary
106                                // signal and must surface either way; a missing
107                                // claim identity or a failed UPDATE falls back
108                                // to the pre-release behaviour (stranded until
109                                // the worker stops heartbeating).
110                                if let (Some(task_id), Some(lock_at), Ok(attempts)) =
111                                    (task_id, lock_at, attempts)
112                                {
113                                    let _ = queries::fail_undecodable_task(
114                                        pool,
115                                        task_id,
116                                        worker_id,
117                                        lock_at,
118                                        attempts,
119                                        error.to_string(),
120                                    )
121                                    .await;
122                                }
123                                Err(error)
124                            }
125                        }
126                    }
127                    Ok(None) => Ok(None),
128                    Err(error) => Err(error),
129                }
130            }
131        })
132        .boxed()
133}
134
135impl PgFetcherSource for PgNotify {
136    const STORAGE_NAME: &'static str = "PostgresStorageWithNotify";
137
138    fn into_compact_stream(
139        self,
140        pool: PgPool,
141        config: Config,
142        worker: WorkerContext,
143        lease_token: Arc<str>,
144    ) -> TaskStream<PgTask<CompactType>, Error> {
145        let ids = queries::notify_task_ids(
146            pool.clone(),
147            config.queue().to_string(),
148            config.buffer_size().max(1),
149        );
150        notify_backed_compact_stream(Self::STORAGE_NAME, ids, pool, config, worker, lease_token)
151    }
152}
153
154/// Shared pipeline composition for the two notify-driven fetcher modes
155/// (`PgNotify` and `SharedFetcher`): initial registration gate, the id→task
156/// batching fetcher fed by `ids`, and the eager polling fetcher merged
157/// alongside as the durable fallback. Factored out so the two impls cannot
158/// drift apart — only the source of notified ids differs between them.
159///
160/// Real batching is provided upstream by the statement-level NOTIFY trigger
161/// (migration 20260521000001), which emits one event per (queue, INSERT
162/// statement) carrying all inserted ids in `ids`. By the time those ids land
163/// in the mpsc channel they are already contiguous, so `ready_chunks` (inside
164/// `batch_ids_into_tasks`) folds them into one batch in the common bursty
165/// case.
166pub(crate) fn notify_backed_compact_stream<Ids>(
167    storage_name: &'static str,
168    ids: Ids,
169    pool: PgPool,
170    config: Config,
171    worker: WorkerContext,
172    lease_token: Arc<str>,
173) -> TaskStream<PgTask<CompactType>, Error>
174where
175    Ids: Stream<Item = Result<PgTaskId, Error>> + Send + 'static,
176{
177    let register_worker = queries::initial_heartbeat(
178        pool.clone(),
179        config.clone(),
180        worker.clone(),
181        storage_name,
182        lease_token,
183    )
184    .map_ok(|_| None);
185
186    let lazy_fetcher = queries::batch_ids_into_tasks(
187        pool.clone(),
188        config.queue().to_string(),
189        worker.name().to_owned(),
190        config.buffer_size().max(1),
191        ids,
192    )
193    .boxed();
194
195    let eager_fetcher = PgPollFetcher::<CompactType>::new(&pool, &config, &worker);
196    let combined = futures::stream::select(lazy_fetcher, eager_fetcher);
197    register_then_stream(register_worker, combined)
198}
199
200/// Internal contract for the concrete fetcher modes (`PgFetcher`, `PgNotify`,
201/// `SharedFetcher`). Lets a single generic `Backend`/`BackendExt` impl on
202/// `PostgresStorage` cover every mode by delegating the pipeline construction
203/// here, instead of repeating identical heartbeat/middleware/poll code three
204/// times. Not part of the public API: downstream code keeps using
205/// `PostgresStorage<Args, Codec, Fetcher>` exactly as before.
206pub(crate) trait PgFetcherSource: Sized + Send + 'static {
207    const STORAGE_NAME: &'static str;
208
209    fn into_compact_stream(
210        self,
211        pool: PgPool,
212        config: Config,
213        worker: apalis_core::worker::context::WorkerContext,
214        lease_token: Arc<str>,
215    ) -> TaskStream<PgTask<CompactType>, Error>;
216}
217
218impl<Decode> PgFetcherSource for PgFetcher<CompactType, Decode>
219where
220    Decode: Send + 'static,
221{
222    const STORAGE_NAME: &'static str = crate::STORAGE_NAME;
223
224    fn into_compact_stream(
225        self,
226        pool: PgPool,
227        config: Config,
228        worker: apalis_core::worker::context::WorkerContext,
229        lease_token: Arc<str>,
230    ) -> TaskStream<PgTask<CompactType>, Error> {
231        let register_worker = queries::initial_heartbeat(
232            pool.clone(),
233            config.clone(),
234            worker.clone(),
235            Self::STORAGE_NAME,
236            lease_token,
237        )
238        .map_ok(|_| None);
239        let fetcher = PgPollFetcher::<CompactType>::new(&pool, &config, &worker);
240        register_then_stream(register_worker, fetcher)
241    }
242}
243
244type Poller = Pin<Box<dyn Stream<Item = ()> + Send>>;
245
246enum StreamState<Args> {
247    WaitForPoll(Poller),
248    StrategyEnded(Delay),
249    Fetch(BoxFuture<'static, Result<Vec<PgTask<Args>>, Error>>),
250    Buffered(VecDeque<PgTask<Args>>),
251}
252
253/// Marker fetcher used by the default polling backend.
254#[derive(Clone, Debug, Default)]
255pub struct PgFetcher<Compact, Decode> {
256    _marker: PhantomData<(Compact, Decode)>,
257}
258
259/// Polling stream that fetches and buffers queued tasks.
260pub(crate) struct PgPollFetcher<Compact> {
261    pool: PgPool,
262    config: Config,
263    worker: WorkerContext,
264    state: StreamState<Compact>,
265    previous_task_count: Arc<AtomicUsize>,
266}
267
268impl<Compact> Clone for PgPollFetcher<Compact> {
269    fn clone(&self) -> Self {
270        let previous_task_count = Arc::new(AtomicUsize::new(0));
271        Self {
272            pool: self.pool.clone(),
273            config: self.config.clone(),
274            worker: self.worker.clone(),
275            state: poll_state(&self.config, &self.worker, previous_task_count.clone()),
276            previous_task_count,
277        }
278    }
279}
280
281impl PgPollFetcher<CompactType> {
282    /// Create a polling fetcher.
283    #[must_use]
284    pub fn new(pool: &PgPool, config: &Config, worker: &WorkerContext) -> Self {
285        let previous_task_count = Arc::new(AtomicUsize::new(0));
286        Self {
287            pool: pool.clone(),
288            config: config.clone(),
289            worker: worker.clone(),
290            state: poll_state(config, worker, previous_task_count.clone()),
291            previous_task_count,
292        }
293    }
294}
295
296/// Delay applied after the configured `PollStrategy` reports exhaustion, before
297/// re-issuing a fetch. Hard-coded rather than configurable because the stream
298/// already self-tunes via `previous_task_count`; the value just smooths a
299/// single edge case (strategy returns `Ready(None)`).
300const STRATEGY_EXHAUSTED_BACKOFF: Duration = Duration::from_millis(100);
301
302impl PgPollFetcher<CompactType> {
303    fn start_fetch(&self) -> StreamState<CompactType> {
304        StreamState::Fetch(
305            queries::fetch_next(self.pool.clone(), self.config.clone(), self.worker.clone())
306                .boxed(),
307        )
308    }
309}
310
311impl<Compact> PgPollFetcher<Compact> {
312    /// Drain buffered tasks that were already fetched but not yet yielded.
313    /// Used by tests to verify the buffered state of the poll fetcher.
314    #[cfg(test)]
315    #[must_use]
316    pub(crate) fn take_pending(&mut self) -> VecDeque<PgTask<Compact>> {
317        match &mut self.state {
318            StreamState::Buffered(tasks) => std::mem::take(tasks),
319            _ => VecDeque::new(),
320        }
321    }
322}
323
324impl Stream for PgPollFetcher<CompactType> {
325    type Item = Result<Option<Task<CompactType, PgContext, ulid::Ulid>>, Error>;
326
327    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
328        let this = self.get_mut();
329
330        loop {
331            match &mut this.state {
332                StreamState::WaitForPoll(poller) => match poller.poll_next_unpin(cx) {
333                    Poll::Pending => return Poll::Pending,
334                    Poll::Ready(Some(())) => {
335                        this.state = this.start_fetch();
336                    }
337                    Poll::Ready(None) => {
338                        this.state =
339                            StreamState::StrategyEnded(Delay::new(STRATEGY_EXHAUSTED_BACKOFF));
340                    }
341                },
342                StreamState::StrategyEnded(delay) => match Pin::new(delay).poll(cx) {
343                    Poll::Pending => return Poll::Pending,
344                    Poll::Ready(()) => {
345                        this.state = this.start_fetch();
346                    }
347                },
348                StreamState::Fetch(fetch) => match fetch.poll_unpin(cx) {
349                    Poll::Pending => return Poll::Pending,
350                    Poll::Ready(Ok(tasks)) if tasks.is_empty() => {
351                        this.previous_task_count.store(0, Ordering::Relaxed);
352                        this.state = poll_state(
353                            &this.config,
354                            &this.worker,
355                            this.previous_task_count.clone(),
356                        );
357                    }
358                    Poll::Ready(Ok(tasks)) => {
359                        this.previous_task_count
360                            .store(tasks.len(), Ordering::Relaxed);
361                        this.state = StreamState::Buffered(VecDeque::from(tasks));
362                    }
363                    Poll::Ready(Err(error)) => {
364                        this.previous_task_count.store(0, Ordering::Relaxed);
365                        this.state = poll_state(
366                            &this.config,
367                            &this.worker,
368                            this.previous_task_count.clone(),
369                        );
370                        return Poll::Ready(Some(Err(error)));
371                    }
372                },
373                StreamState::Buffered(buffer) => {
374                    if let Some(task) = buffer.pop_front() {
375                        if buffer.is_empty() {
376                            this.state = poll_state(
377                                &this.config,
378                                &this.worker,
379                                this.previous_task_count.clone(),
380                            );
381                        }
382                        return Poll::Ready(Some(Ok(Some(task))));
383                    }
384                    this.state =
385                        poll_state(&this.config, &this.worker, this.previous_task_count.clone());
386                }
387            }
388        }
389    }
390}
391
392fn poll_state<Compact>(
393    config: &Config,
394    worker: &WorkerContext,
395    previous_task_count: Arc<AtomicUsize>,
396) -> StreamState<Compact> {
397    let context = PollContext::new(worker.clone(), previous_task_count);
398    StreamState::WaitForPoll(config.poll_strategy().clone().build_stream(&context))
399}
400
401#[cfg(test)]
402mod tests {
403    use std::{
404        collections::VecDeque,
405        pin::Pin,
406        sync::{
407            Arc,
408            atomic::{AtomicUsize, Ordering},
409        },
410        task::{Context, Poll},
411        time::Duration,
412    };
413
414    use apalis_core::{task::builder::TaskBuilder, worker::context::WorkerContext};
415    use diesel::{
416        PgConnection,
417        r2d2::{ConnectionManager, Pool},
418    };
419    use futures::{FutureExt, future, stream, task::noop_waker_ref};
420    use lets_expect::{AssertionError, AssertionResult, *};
421
422    use super::*;
423
424    struct PollObservation {
425        poll: &'static str,
426        state: &'static str,
427        previous_task_count: usize,
428    }
429
430    fn unchecked_pool() -> PgPool {
431        let manager = ConnectionManager::<PgConnection>::new("postgres://127.0.0.1:1/not-used");
432        Pool::builder()
433            .max_size(1)
434            .connection_timeout(Duration::from_millis(10))
435            .build_unchecked(manager)
436    }
437
438    fn buffered_fetcher() -> PgPollFetcher<CompactType> {
439        PgPollFetcher {
440            pool: unchecked_pool(),
441            config: Config::new("fetcher-test"),
442            worker: WorkerContext::new::<()>("fetcher-worker"),
443            state: StreamState::Buffered(VecDeque::new()),
444            previous_task_count: Arc::new(AtomicUsize::new(12)),
445        }
446    }
447
448    fn state_name(fetcher: &PgPollFetcher<CompactType>) -> &'static str {
449        match &fetcher.state {
450            StreamState::WaitForPoll(_) => "wait_for_poll",
451            StreamState::StrategyEnded(_) => "strategy_ended",
452            StreamState::Fetch(_) => "fetch",
453            StreamState::Buffered(_) => "buffered",
454        }
455    }
456
457    fn poll_observation(fetcher: &mut PgPollFetcher<CompactType>) -> PollObservation {
458        let mut cx = Context::from_waker(noop_waker_ref());
459        let poll = match Pin::new(&mut *fetcher).poll_next(&mut cx) {
460            Poll::Ready(Some(Ok(Some(_)))) => "task",
461            Poll::Ready(Some(Ok(None))) => "empty",
462            Poll::Ready(Some(Err(_))) => "error",
463            Poll::Ready(None) => "closed",
464            Poll::Pending => "pending",
465        };
466        PollObservation {
467            poll,
468            state: state_name(fetcher),
469            previous_task_count: fetcher.previous_task_count.load(Ordering::Relaxed),
470        }
471    }
472
473    fn pending_poll_strategy_observation() -> PollObservation {
474        let mut fetcher = buffered_fetcher();
475        fetcher.state = StreamState::WaitForPoll(Box::pin(stream::pending()));
476        poll_observation(&mut fetcher)
477    }
478
479    fn exhausted_poll_strategy_observation() -> PollObservation {
480        // Stream::poll_next returning `Ready(None)` must transition the
481        // fetcher into `StrategyEnded` (fetcher.rs:106-109) — the only way
482        // out of WaitForPoll besides starting a fetch.
483        let mut fetcher = buffered_fetcher();
484        fetcher.state = StreamState::WaitForPoll(Box::pin(stream::empty::<()>()));
485        poll_observation(&mut fetcher)
486    }
487
488    fn observed_strategy_exhaustion(result: &PollObservation) -> AssertionResult {
489        match (result.poll, result.state) {
490            // After the strategy ends, the fetcher enters StrategyEnded and
491            // its Delay (100 ms, fetcher.rs:108) has not yet elapsed in this
492            // synchronous test — so the outer poll returns Pending.
493            ("pending", "strategy_ended") => Ok(()),
494            other => Err(AssertionError::new(vec![format!(
495                "expected exhausted strategy to transition into strategy_ended/pending, got {other:?}"
496            )])),
497        }
498    }
499
500    fn fetch_error_observation() -> PollObservation {
501        let mut fetcher = buffered_fetcher();
502        fetcher.state = StreamState::Fetch(future::ready(Err(Error::SinkBufferFull(1))).boxed());
503        poll_observation(&mut fetcher)
504    }
505
506    fn empty_fetch_observation() -> PollObservation {
507        let mut fetcher = buffered_fetcher();
508        fetcher.state = StreamState::Fetch(future::ready(Ok(Vec::new())).boxed());
509        poll_observation(&mut fetcher)
510    }
511
512    fn successful_fetch_observation() -> PollObservation {
513        let mut fetcher = buffered_fetcher();
514        let task = TaskBuilder::new(vec![1, 2, 3])
515            .with_ctx(PgContext::new())
516            .build();
517        fetcher.state = StreamState::Fetch(future::ready(Ok(vec![task])).boxed());
518        poll_observation(&mut fetcher)
519    }
520
521    fn fetch_pending_observation() -> PollObservation {
522        let mut fetcher = buffered_fetcher();
523        fetcher.state = StreamState::Fetch(future::pending().boxed());
524        poll_observation(&mut fetcher)
525    }
526
527    fn cloned_state(fetcher: &PgPollFetcher<CompactType>) -> &'static str {
528        match &fetcher.clone().state {
529            StreamState::WaitForPoll(_) => "wait_for_poll",
530            StreamState::StrategyEnded(_) => "strategy_ended",
531            StreamState::Fetch(_) => "fetch",
532            StreamState::Buffered(_) => "buffered",
533        }
534    }
535
536    fn cloned_previous_task_count(fetcher: &PgPollFetcher<CompactType>) -> usize {
537        fetcher.clone().previous_task_count.load(Ordering::Relaxed)
538    }
539
540    fn observed_fetch_error(result: &PollObservation) -> AssertionResult {
541        match (result.poll, result.state, result.previous_task_count) {
542            ("error", "wait_for_poll", 0) => Ok(()),
543            other => Err(AssertionError::new(vec![format!(
544                "expected fetch error to reset the poll strategy, got {other:?}"
545            )])),
546        }
547    }
548
549    fn observed_empty_fetch(result: &PollObservation) -> AssertionResult {
550        match (result.poll, result.state, result.previous_task_count) {
551            ("pending", "wait_for_poll", 0) => Ok(()),
552            other => Err(AssertionError::new(vec![format!(
553                "expected empty fetch to wait for configured polling, got {other:?}"
554            )])),
555        }
556    }
557
558    fn observed_successful_fetch(result: &PollObservation) -> AssertionResult {
559        match (result.poll, result.state, result.previous_task_count) {
560            ("task", "wait_for_poll", 1) => Ok(()),
561            other => Err(AssertionError::new(vec![format!(
562                "expected successful fetch to yield one task and remember the count, got {other:?}"
563            )])),
564        }
565    }
566
567    fn observed_pending_fetch(result: &PollObservation) -> AssertionResult {
568        // The in-flight fetch future is still Pending (fetcher.rs:349): the
569        // poll returns Pending without mutating the state slot or the
570        // previously remembered batch count (12 from `buffered_fetcher`).
571        match (result.poll, result.state, result.previous_task_count) {
572            ("pending", "fetch", 12) => Ok(()),
573            other => Err(AssertionError::new(vec![format!(
574                "expected an in-flight fetch to wait without touching the batch count, got {other:?}"
575            )])),
576        }
577    }
578
579    fn observed_pending_strategy(result: &PollObservation) -> AssertionResult {
580        match (result.poll, result.state, result.previous_task_count) {
581            ("pending", "wait_for_poll", 12) => Ok(()),
582            other => Err(AssertionError::new(vec![format!(
583                "expected pending strategy to prevent a database fetch, got {other:?}"
584            )])),
585        }
586    }
587
588    fn buffered_with(tasks: Vec<PgTask<CompactType>>) -> PgPollFetcher<CompactType> {
589        let mut fetcher = buffered_fetcher();
590        fetcher.state = StreamState::Buffered(VecDeque::from(tasks));
591        fetcher
592    }
593
594    fn synthetic_task(payload: &[u8]) -> PgTask<CompactType> {
595        TaskBuilder::new(payload.to_vec())
596            .with_ctx(PgContext::new())
597            .build()
598    }
599
600    fn take_pending_count(state_kind: &'static str) -> usize {
601        let mut fetcher = match state_kind {
602            "buffered_two" => buffered_with(vec![synthetic_task(b"one"), synthetic_task(b"two")]),
603            "buffered_empty" => buffered_with(Vec::new()),
604            "wait_for_poll" => {
605                let mut fetcher = buffered_fetcher();
606                fetcher.state = StreamState::WaitForPoll(Box::pin(stream::pending()));
607                fetcher
608            }
609            "fetch" => {
610                let mut fetcher = buffered_fetcher();
611                fetcher.state = StreamState::Fetch(future::ready(Ok(Vec::new())).boxed());
612                fetcher
613            }
614            "strategy_ended" => {
615                let mut fetcher = buffered_fetcher();
616                fetcher.state = StreamState::StrategyEnded(Delay::new(Duration::from_secs(60)));
617                fetcher
618            }
619            other => panic!("unknown state kind: {other}"),
620        };
621        fetcher.take_pending().len()
622    }
623
624    /// After `take_pending` drains the buffer, the fetcher should still be in
625    /// the same Buffered state slot (we only stole the inner VecDeque). The
626    /// follow-up observation confirms the buffer is now empty and the next
627    /// `poll_next` would transition to WaitForPoll.
628    fn take_pending_drains_then_reports_empty() -> (usize, usize, &'static str) {
629        let mut fetcher = buffered_with(vec![synthetic_task(b"alpha"), synthetic_task(b"beta")]);
630        let drained = fetcher.take_pending().len();
631        let remaining = match &fetcher.state {
632            StreamState::Buffered(tasks) => tasks.len(),
633            _ => panic!("take_pending changed the state slot"),
634        };
635        (drained, remaining, state_name(&fetcher))
636    }
637
638    fn buffered_pop_front_observation() -> PollObservation {
639        let mut fetcher = buffered_with(vec![synthetic_task(b"first"), synthetic_task(b"second")]);
640        poll_observation(&mut fetcher)
641    }
642
643    fn observed_buffered_pop_front(result: &PollObservation) -> AssertionResult {
644        // `buffered_fetcher` is constructed with `previous_task_count=12`; a
645        // pop from the buffered state should NOT touch that counter (only a
646        // fresh fetch_next outcome updates it). Yields the task while the
647        // buffer still holds a sibling task.
648        match (result.poll, result.state, result.previous_task_count) {
649            ("task", "buffered", 12) => Ok(()),
650            other => Err(AssertionError::new(vec![format!(
651                "expected pop_front to yield a task while remaining buffered, got {other:?}"
652            )])),
653        }
654    }
655
656    /// Poll twice on a single-element Buffered state. The first call should
657    /// yield the task and emit a transition to WaitForPoll (the buffer is now
658    /// empty). The second call sits in WaitForPoll.
659    fn buffered_drain_observation() -> &'static str {
660        let mut fetcher = buffered_with(vec![synthetic_task(b"only")]);
661        let mut cx = Context::from_waker(noop_waker_ref());
662        let _ = Pin::new(&mut fetcher).poll_next(&mut cx);
663        state_name(&fetcher)
664    }
665
666    lets_expect! {
667        expect(cloned_state(&fetcher)) {
668            let fetcher = buffered_fetcher();
669
670            when original_stream_has_buffered_state {
671                to resets_the_clone_to_poll_strategy { equal("wait_for_poll") }
672            }
673        }
674
675        expect(cloned_previous_task_count(&fetcher)) {
676            let fetcher = buffered_fetcher();
677
678            when original_stream_remembers_a_previous_batch {
679                to starts_the_clone_with_no_previous_count { equal(0) }
680            }
681        }
682
683        expect(pending_poll_strategy_observation()) {
684            when the_configured_poll_strategy_is_not_ready {
685                to does_not_start_a_fetch { observed_pending_strategy }
686            }
687        }
688
689        expect(exhausted_poll_strategy_observation()) {
690            when the_configured_poll_strategy_returns_ready_none {
691                to transitions_into_strategy_ended_and_waits_for_the_delay {
692                    observed_strategy_exhaustion
693                }
694            }
695        }
696
697        expect(fetch_error_observation()) {
698            when fetch_query_fails {
699                to yields_the_error_and_waits_for_the_next_poll_signal { observed_fetch_error }
700            }
701        }
702
703        expect(empty_fetch_observation()) {
704            when fetch_returns_no_tasks {
705                to waits_for_the_next_configured_poll_signal { observed_empty_fetch }
706            }
707        }
708
709        expect(successful_fetch_observation()) {
710            when fetch_returns_tasks {
711                to yields_a_task_and_records_the_batch_size { observed_successful_fetch }
712            }
713        }
714
715        expect(fetch_pending_observation()) {
716            when fetch_query_is_still_in_flight {
717                to waits_without_touching_the_batch_count { observed_pending_fetch }
718            }
719        }
720
721        expect(take_pending_count(state_kind)) {
722            let state_kind = "buffered_two";
723
724            when fetcher_is_in_buffered_state_with_two_tasks {
725                to drains_every_buffered_task { equal(2) }
726            }
727
728            when fetcher_is_in_buffered_state_with_no_tasks {
729                let state_kind = "buffered_empty";
730                to returns_an_empty_drained_queue { equal(0) }
731            }
732
733            when fetcher_is_in_wait_for_poll_state {
734                let state_kind = "wait_for_poll";
735                to ignores_states_other_than_buffered { equal(0) }
736            }
737
738            when fetcher_is_in_fetch_state {
739                let state_kind = "fetch";
740                to ignores_states_other_than_buffered { equal(0) }
741            }
742
743            when fetcher_is_in_strategy_ended_state {
744                let state_kind = "strategy_ended";
745                to ignores_states_other_than_buffered { equal(0) }
746            }
747        }
748
749        expect(take_pending_drains_then_reports_empty()) {
750            when buffered_state_is_drained_via_take_pending {
751                to leaves_the_fetcher_in_the_buffered_state_with_zero_tasks {
752                    equal((2, 0, "buffered"))
753                }
754            }
755        }
756
757        expect(buffered_pop_front_observation()) {
758            when buffer_holds_multiple_tasks {
759                to pops_a_task_and_stays_in_buffered { observed_buffered_pop_front }
760            }
761        }
762
763        expect(buffered_drain_observation()) {
764            when buffer_holds_exactly_one_task {
765                to transitions_to_wait_for_poll_after_emitting_the_task {
766                    equal("wait_for_poll")
767                }
768            }
769        }
770    }
771}