Skip to main content

agentplane/push/
delivery.rs

1//! The journal-as-outbox loop, with the payload left to the caller.
2//!
3//! # Why this is not inside the A2A server any more
4//!
5//! The cursor discipline — read from the journal past `next_seq`, POST, advance
6//! only on 2xx, back off on anything else, give up on a permanent refusal — is
7//! the part of push worth having, and it has nothing to do with A2A. It lived
8//! inside `A2aPushWorker` because A2A was the first caller, which made the one
9//! thing an operator most wants reachable only by speaking somebody else's
10//! protocol and only for a **caller-supplied** URL.
11//!
12//! So the loop is here, parameterised by a [`Projection`]: *what should this
13//! record be sent as, and does it end the stream?* A2A answers with
14//! `StreamResponse`; a deployment answers with whatever its own bus consumes.
15//! One implementation, because two copies of a retry policy agree everywhere
16//! except the boundary nobody probes.
17//!
18//! # At-least-once, stated as a preference
19//!
20//! A crash after POST and before the cursor is persisted repeats an event. That
21//! is the direction chosen deliberately: the alternative — advance first, then
22//! POST — loses events on the same crash, and a receiver that must tolerate
23//! duplicates is a receiver that can be built, while one that must tolerate
24//! silence is not.
25
26use std::fmt::Debug;
27use std::sync::Arc;
28
29use crate::core::StoreError;
30use crate::journal::{JournalStore, Record, RecordKind};
31use async_trait::async_trait;
32
33use super::{Delivered, PushMessage, PushNamespace, PushRegistration, PushStore, PushTransport};
34
35/// What one journal record should be delivered as.
36///
37/// Returning **several** messages for one record is deliberate: A2A's status and
38/// artifact events are two messages derived from one append, and a projection
39/// that could only answer with one would force the caller to invent a second
40/// cursor. An empty vector means *nothing to send for this record*, and the
41/// cursor still advances past it — which is how a projection filters.
42#[async_trait]
43pub trait Projection: Send + Sync + Debug {
44    /// The messages this record becomes, in delivery order.
45    ///
46    /// Each carries its own media type and its own identity, because those are
47    /// facts about the payload and not about the loop: two projections share
48    /// this worker and speak different wires, and a receiver that cannot
49    /// recognise a repeat has no defence against at-least-once delivery. See
50    /// [`PushMessage`].
51    ///
52    /// # Errors
53    ///
54    /// [`StoreError`] when the record cannot be projected — a materialisation
55    /// that needs a blob store, say. The worker treats this as **transient and
56    /// this plane's own fault**: the cursor does not move, so the same record is
57    /// tried again, and the retry ceiling still applies because a record that
58    /// cannot be projected now will not project on the next tick either.
59    async fn messages(&self, record: &Record) -> Result<Vec<PushMessage>, StoreError>;
60
61    /// Whether this record ends the stream for its run.
62    ///
63    /// The registration is deleted once its payloads are acknowledged, because a
64    /// cursor sitting forever at the end of a sealed run's journal is a row that
65    /// is scanned on every tick and can never move.
66    ///
67    /// The default is [`RecordKind::RunConcluded`], which is the honest answer for
68    /// anything keyed on a run: nothing is appended after a seal.
69    fn terminal(&self, record: &Record) -> bool {
70        matches!(record.kind(), RecordKind::RunConcluded { .. })
71    }
72
73    /// Which id namespace this worker serves.
74    ///
75    /// Two workers share one store: the A2A worker serves caller-registered
76    /// webhooks, the outbox worker serves operator destinations. Without this
77    /// each would deliver the other's rows with its own projection — an
78    /// operator's `CloudEvents` message to a peer's A2A webhook, and a `StreamResponse` to
79    /// the deployment's bus.
80    ///
81    /// A declaration rather than a per-row predicate, because the split is the
82    /// **store's** discriminator: the worker hands it to
83    /// [`PushStore::due_in`](super::PushStore::due_in) so the query itself
84    /// filters, instead of reading a bounded window and dropping the rows it
85    /// does not own — which starves this worker's rows the moment the other
86    /// namespace fills the window.
87    fn namespace(&self) -> PushNamespace;
88}
89
90/// Outcome of one bounded delivery sweep.
91#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
92pub struct PushSweepReport {
93    pub registrations: usize,
94    pub records: usize,
95    pub deliveries: usize,
96    pub retries: usize,
97    pub completed: usize,
98    /// Registrations this sweep stopped delivering to: a permanent refusal, or
99    /// a receiver that stayed unreachable past the worker's retry ceiling.
100    ///
101    /// Counted separately from `completed` because they are opposite outcomes
102    /// wearing one shape — both take the registration out of the due order, and
103    /// only one of them delivered anything. The rows themselves survive with
104    /// their cursors, listed by [`PushStore::parked`](super::PushStore::parked);
105    /// this number is how an operator learns there is a list to read.
106    pub parked: usize,
107    /// Due rows in the **other** id namespace — work this worker must not
108    /// touch, surfaced so it cannot be invisible.
109    ///
110    /// A deployment that scheduled only one of the two workers has a backlog
111    /// no sweep will ever serve, and before this field its report was
112    /// byte-identical to a quiet plane's. Deliberately **not** part of
113    /// [`needs_attention`](Self::needs_attention): whenever both workers run,
114    /// the other's rows are legitimately due in the instants between its
115    /// sweeps, and one tick cannot tell that ordinary overlap from an
116    /// unserved namespace — so this is visibility, judged by the operator who
117    /// knows which workers exist.
118    pub unserved: usize,
119    pub saturated: bool,
120}
121
122impl PushSweepReport {
123    /// Whether this tick found anything a human should see.
124    ///
125    /// A webhook that will never be delivered to must not produce a line
126    /// byte-identical to a receiver that is merely rebooting: a single
127    /// `retries: 1` is the ordinary shape of both, and only parking
128    /// distinguishes them.
129    #[must_use]
130    pub const fn needs_attention(&self) -> bool {
131        // A backlog and a quiet plane must not produce the same numbers.
132        self.saturated || self.parked > 0
133    }
134}
135
136/// Durable outbound delivery, driven by an operator's scheduler.
137///
138/// The clock is the caller's: [`run_once`](Self::run_once) takes `at` rather
139/// than reading one, so backoff is testable and a deployment owns its own
140/// cadence. Several instances may sweep concurrently — duplicates are within
141/// contract, and cursor updates advance monotonically so a race cannot lose an
142/// event.
143#[derive(Debug, Clone)]
144pub struct DeliveryWorker {
145    journal: Arc<dyn JournalStore>,
146    store: Arc<dyn PushStore>,
147    transport: Arc<dyn PushTransport>,
148    projection: Arc<dyn Projection>,
149    max_attempts: u32,
150    max_in_flight: usize,
151}
152
153/// What one registration's turn achieved.
154///
155/// Separate from [`PushSweepReport`] because the two answer different
156/// questions: this one is per-registration and is summed, while the report also
157/// carries facts about the *sweep* — saturation, the other namespace's backlog
158/// — that no single registration can observe. Folding into the report directly
159/// would mean every concurrent turn holding the same mutable value, which is
160/// the ordering the fan-out exists to remove.
161#[derive(Debug, Default, Clone, Copy)]
162struct Progress {
163    records: usize,
164    deliveries: usize,
165    retries: usize,
166    completed: usize,
167    parked: usize,
168}
169
170impl DeliveryWorker {
171    /// How many consecutive failures a receiver gets before it is parked.
172    ///
173    /// The schedule is `1 << min(attempts, 8)` seconds, jittered downward and
174    /// capped at 256 s, so 32 attempts is between about fifty minutes and one
175    /// hour forty of a receiver being down — a reboot, a deploy or a
176    /// certificate renewal, and not a webhook that has gone away. Past it the
177    /// registration is [parked](super::PushStore::park) and *reported*: the
178    /// alternative is a row retried until the journal is deleted, and a queue
179    /// that only ever grows is one nobody can read.
180    pub const DEFAULT_MAX_ATTEMPTS: u32 = 32;
181
182    /// The longest a receiver's own `Retry-After` may park a registration.
183    ///
184    /// Advice is honoured because a receiver naming its recovery knows better
185    /// than a fixed schedule — but it is *advice*, from the one party with an
186    /// interest in never being called again. An hour is long enough for any
187    /// real rate limit and short enough that a hostile or broken value costs
188    /// one wasted hour rather than the life of the deployment.
189    pub const MAX_RETRY_AFTER: u64 = 3_600;
190
191    /// How many receivers one sweep talks to at once.
192    ///
193    /// The number bounds *this plane's* outbound sockets, not any one
194    /// receiver's load — two registrations pointing at the same host are
195    /// ordinary, and spreading their retries is the backoff schedule's job
196    /// rather than this ceiling's. Sixteen is enough that a handful of slow
197    /// receivers cannot
198    /// stall a sweep and small enough that a plane with a large backlog does
199    /// not answer a scheduler tick by opening a thousand connections.
200    pub const DEFAULT_MAX_IN_FLIGHT: usize = 16;
201}
202
203/// How many journal records one registration drains per turn.
204///
205/// A page, not a ceiling: the cursor advances per delivery and the row stays
206/// due, so a deep backlog finishes over turns instead of holding one of the
207/// sweep's fan-out slots for its whole length.
208const DRAIN_PAGE: usize = 256;
209
210impl DeliveryWorker {
211    #[must_use]
212    pub fn new(
213        journal: Arc<dyn JournalStore>,
214        store: Arc<dyn PushStore>,
215        transport: Arc<dyn PushTransport>,
216        projection: Arc<dyn Projection>,
217    ) -> Self {
218        Self {
219            journal,
220            store,
221            transport,
222            projection,
223            max_attempts: Self::DEFAULT_MAX_ATTEMPTS,
224            max_in_flight: Self::DEFAULT_MAX_IN_FLIGHT,
225        }
226    }
227
228    /// Change how many receivers one sweep talks to at once.
229    ///
230    /// # Panics
231    ///
232    /// If `n` is zero — a sweep that may contact nobody is not a concurrency
233    /// setting, it is an off switch spelled as one, and it would report a
234    /// perfectly quiet plane forever.
235    #[must_use]
236    pub const fn max_in_flight(mut self, n: usize) -> Self {
237        assert!(
238            n > 0,
239            "a push concurrency of zero contacts no receiver and reports a \
240             quiet plane; schedule no worker instead"
241        );
242        self.max_in_flight = n;
243        self
244    }
245
246    /// Change the retry ceiling.
247    ///
248    /// # Panics
249    ///
250    /// If `attempts` is zero — that would spell *never deliver anything* as if
251    /// it were a retry policy.
252    #[must_use]
253    pub const fn max_attempts(mut self, attempts: u32) -> Self {
254        assert!(
255            attempts > 0,
256            "a push retry ceiling of zero abandons every receiver on its first \
257             hiccup; configure no push instead"
258        );
259        self.max_attempts = attempts;
260        self
261    }
262
263    /// Deliver at most `limit` due registrations once.
264    ///
265    /// `at` is Unix time in seconds and is explicit to make backoff tests
266    /// deterministic. The operator owns scheduling and the clock.
267    ///
268    /// # Giving up is a state, not a deletion
269    ///
270    /// Two failures stop a registration rather than rescheduling it, and both
271    /// are counted in [`PushSweepReport::parked`]: a **permanent** answer —
272    /// [`PushError::is_permanent`](super::PushError::is_permanent) for a host
273    /// taken off the allowlist, a URL that is not HTTPS or does not parse, and
274    /// [`Delivered::is_permanent`] for a receiver answering 410 Gone — which no
275    /// backoff improves; and a transient failure that has happened
276    /// [`max_attempts`](Self::max_attempts) times, which is a receiver that is
277    /// not coming back on its own.
278    ///
279    /// Either way the row keeps its cursor. That is the difference between a
280    /// backlog and a loss: an operator who fixes the receiver calls
281    /// [`PushStore::unpark`](super::PushStore::unpark) and delivery resumes at
282    /// the first record that receiver never acknowledged.
283    ///
284    /// # Errors
285    ///
286    /// [`StoreError`] when the push store or the journal cannot be read.
287    pub async fn run_once(
288        &self,
289        at: u64,
290        limit: usize,
291    ) -> Result<PushSweepReport, crate::core::StoreError> {
292        use futures_util::StreamExt as _;
293
294        // One over the limit, so a saturated sweep is distinguishable from one
295        // that happened to fill the page exactly. The namespace filter rides
296        // in the query itself: filtering a bounded read afterwards let rows of
297        // the other namespace occupy the whole window, starving this worker's
298        // own rows while its report read as a quiet plane.
299        let batch = self
300            .store
301            .due_in(at, limit.saturating_add(1), self.projection.namespace())
302            .await?;
303        let saturated = batch.rows.len() > limit;
304        let mut report = PushSweepReport {
305            registrations: batch.rows.len().min(limit),
306            unserved: batch.unserved,
307            saturated,
308            ..PushSweepReport::default()
309        };
310
311        // Registrations are independent — different tasks, different receivers,
312        // different cursors — so nothing about the cursor discipline requires
313        // them to be served in order. Serving them in order requires only that
314        // every receiver be as fast as the slowest, which is not a property
315        // anybody can arrange: one endpoint sitting on its 15 s timeout would
316        // hold the whole sweep, and a plane with more registrations than a
317        // sweep can reach in its tick falls permanently behind on all of them.
318        //
319        // Bounded rather than unbounded: a sweep is the one place this crate
320        // knows how much outbound work exists, and `limit` is a page size
321        // rather than a concurrency budget. Ordering **within** one
322        // registration is untouched — that loop is still strictly sequential,
323        // because its whole content is a cursor that may only move forward.
324        let outcomes: Vec<_> = futures_util::stream::iter(
325            batch
326                .rows
327                .into_iter()
328                .take(limit)
329                .map(|registration| self.deliver_one(registration, at)),
330        )
331        .buffer_unordered(self.max_in_flight)
332        .collect()
333        .await;
334
335        for outcome in outcomes {
336            let progress = outcome?;
337            report.records += progress.records;
338            report.deliveries += progress.deliveries;
339            report.retries += progress.retries;
340            report.completed += progress.completed;
341            report.parked += progress.parked;
342        }
343        Ok(report)
344    }
345
346    /// Drain one registration's backlog, as far as its receiver allows.
347    ///
348    /// Sequential by construction, and that is the invariant rather than an
349    /// implementation detail: the cursor is a promise that everything before it
350    /// was acknowledged, so a record may not be delivered until the one before
351    /// it was. Concurrency across registrations is free; concurrency within one
352    /// would break the only thing the cursor means.
353    async fn deliver_one(
354        &self,
355        registration: PushRegistration,
356        at: u64,
357    ) -> Result<Progress, crate::core::StoreError> {
358        let mut progress = Progress::default();
359        let mut attempts = registration.attempts;
360        let mut records = self
361            .journal
362            .read(registration.config.task, registration.next_seq)
363            .await?;
364        // Bounded per turn: one registration with a 10k-record backlog would
365        // otherwise hold one of the sweep's fan-out slots for its whole drain,
366        // which is the serialisation problem re-created one slot at a time.
367        // The cursor advances per delivery, the row stays due, and the rest of
368        // the backlog is the next turn's page.
369        records.truncate(DRAIN_PAGE);
370        if records.is_empty() && self.cleanup_acknowledged_terminal(&registration).await? {
371            progress.completed += 1;
372            return Ok(progress);
373        }
374        for record in records {
375            let messages = match self.projection.messages(&record).await {
376                Ok(messages) => messages,
377                Err(error) => {
378                    // A projection failure is this plane's own bug, never the
379                    // receiver's, so it is always transient here — the ceiling
380                    // still applies, because a record that cannot be projected
381                    // cannot be projected on the next tick either and the
382                    // cursor never moves past it.
383                    self.give_up_or_retry(
384                        &registration,
385                        at,
386                        attempts,
387                        &Failure::transient(error.to_string()),
388                        &mut progress,
389                    )
390                    .await?;
391                    break;
392                }
393            };
394            let mut failed = None;
395            for message in messages {
396                match self
397                    .transport
398                    .deliver(&registration.config, &message, at)
399                    .await
400                {
401                    Ok(Delivered::Accepted) => progress.deliveries += 1,
402                    Ok(other) => {
403                        failed = Some(Failure {
404                            error: format!("receiver outcome: {other:?}"),
405                            permanent: other.is_permanent(),
406                            retry_after: other.retry_after(),
407                        });
408                    }
409                    Err(error) => {
410                        failed = Some(Failure {
411                            permanent: error.is_permanent(),
412                            error: error.to_string(),
413                            retry_after: None,
414                        });
415                    }
416                }
417                if failed.is_some() {
418                    break;
419                }
420            }
421            if let Some(failure) = failed {
422                self.give_up_or_retry(&registration, at, attempts, &failure, &mut progress)
423                    .await?;
424                break;
425            }
426            self.store
427                .advance(
428                    registration.config.task,
429                    &registration.config.id,
430                    record.body.seq.saturating_add(1),
431                )
432                .await?;
433            attempts = 0;
434            progress.records += 1;
435            if self.projection.terminal(&record) {
436                self.store
437                    .delete(registration.config.task, &registration.config.id)
438                    .await?;
439                progress.completed += 1;
440                break;
441            }
442        }
443        Ok(progress)
444    }
445
446    /// Reschedule one failed registration, or park it.
447    ///
448    /// One implementation, because the two call sites above are the same
449    /// decision and a second copy of it is the shape that agrees everywhere
450    /// except the boundary nobody probed.
451    async fn give_up_or_retry(
452        &self,
453        registration: &PushRegistration,
454        at: u64,
455        attempts: u32,
456        failure: &Failure,
457        report: &mut Progress,
458    ) -> Result<(), crate::core::StoreError> {
459        // `attempts` counts the failures *before* this one, so the ceiling is
460        // reached when this failure makes it up to the ceiling — not one tick
461        // later, which would make `max_attempts(1)` mean two attempts.
462        let exhausted = attempts.saturating_add(1) >= self.max_attempts;
463        if failure.permanent || exhausted {
464            let reason = if failure.permanent {
465                "this destination answered permanently"
466            } else {
467                "the receiver did not answer within the retry ceiling"
468            };
469            tracing::warn!(
470                task = %registration.config.task,
471                config = %registration.config.id,
472                host = %crate::netguard::host_of(&registration.config.url),
473                attempts = attempts.saturating_add(1),
474                error = %failure.error,
475                "parking a push registration: {reason} — its cursor is kept, so \
476                 `unpark` resumes at the first record the receiver never took"
477            );
478            self.store
479                .park(
480                    registration.config.task,
481                    &registration.config.id,
482                    &failure.error,
483                )
484                .await?;
485            report.parked += 1;
486            return Ok(());
487        }
488        self.store
489            .retry(
490                registration.config.task,
491                &registration.config.id,
492                Self::next_attempt_at(registration, at, attempts, failure.retry_after),
493                &failure.error,
494            )
495            .await?;
496        report.retries += 1;
497        Ok(())
498    }
499
500    /// When to try this registration again.
501    ///
502    /// The receiver's own `Retry-After` wins when it named one, bounded by
503    /// [`MAX_RETRY_AFTER`](Self::MAX_RETRY_AFTER). Otherwise the schedule is
504    /// exponential — `1 << min(attempts, 8)`, capping at 256 s — with the delay
505    /// drawn from the lower half of that window rather than sitting exactly on
506    /// it.
507    ///
508    /// # Why the jitter is derived and not random
509    ///
510    /// Every registration that failed against one receiver failed in the same
511    /// sweep, so an undithered schedule sends all of them back at the *same
512    /// instant* — and the moment a receiver recovers it is hit by its entire
513    /// backlog at once, which is how a recovering service is knocked over by
514    /// the sender that was waiting politely. Spreading them is the fix; drawing
515    /// the spread from a random source would make backoff untestable and every
516    /// sweep unreproducible, which this worker deliberately is not — `at` is
517    /// the caller's for exactly that reason. So the offset is a hash of the
518    /// registration and its attempt count: fixed for a given row and tick,
519    /// uncorrelated between rows.
520    fn next_attempt_at(
521        registration: &PushRegistration,
522        at: u64,
523        attempts: u32,
524        advice: Option<u64>,
525    ) -> u64 {
526        if let Some(seconds) = advice {
527            return at.saturating_add(seconds.clamp(1, Self::MAX_RETRY_AFTER));
528        }
529        let window = 1u64 << attempts.min(8);
530        let half = window / 2;
531        let offset = spread(registration, attempts) % (half.saturating_add(1));
532        at.saturating_add(half.saturating_add(offset).max(1))
533    }
534
535    /// Remove a registration whose run sealed and whose seal was acknowledged.
536    ///
537    /// Needed because the terminal record may have been delivered by a sweep
538    /// that crashed after `advance` and before `delete`. Without it the row
539    /// stays forever, read on every tick, with nothing left to send.
540    async fn cleanup_acknowledged_terminal(
541        &self,
542        registration: &PushRegistration,
543    ) -> Result<bool, crate::core::StoreError> {
544        if registration.next_seq <= 1 {
545            return Ok(false);
546        }
547        let previous = self
548            .journal
549            .read(
550                registration.config.task,
551                registration.next_seq.saturating_sub(1),
552            )
553            .await?;
554        let completed = previous.last().is_some_and(|record| {
555            record.body.seq.saturating_add(1) == registration.next_seq
556                && self.projection.terminal(record)
557        });
558        if completed {
559            self.store
560                .delete(registration.config.task, &registration.config.id)
561                .await?;
562        }
563        Ok(completed)
564    }
565}
566
567/// One failed attempt, as the retry decision needs to see it.
568///
569/// A struct rather than three positional arguments because the two call sites
570/// disagreed about their order once, and a `bool` that means *permanent* next
571/// to a `bool` that means anything else is a defect nothing catches.
572struct Failure {
573    error: String,
574    permanent: bool,
575    retry_after: Option<u64>,
576}
577
578impl Failure {
579    fn transient(error: String) -> Self {
580        Self {
581            error,
582            permanent: false,
583            retry_after: None,
584        }
585    }
586}
587
588/// A registration's fixed place in the retry window, in seconds.
589///
590/// SHA-256 rather than `DefaultHasher`, whose output is stable only within one
591/// Rust version: a backoff instant that moved with the toolchain would make
592/// every schedule assertion a version pin.
593fn spread(registration: &PushRegistration, attempts: u32) -> u64 {
594    use sha2::Digest as _;
595    let mut hasher = sha2::Sha256::new();
596    hasher.update(registration.config.task.to_string().as_bytes());
597    hasher.update([0x1f]);
598    hasher.update(registration.config.id.as_bytes());
599    hasher.update([0x1f]);
600    hasher.update(attempts.to_be_bytes());
601    let digest = hasher.finalize();
602    u64::from_be_bytes(digest[..8].try_into().expect("SHA-256 is 32 bytes"))
603}