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(®istration).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 ®istration,
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(®istration.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(®istration, at, attempts, &failure, &mut progress)
423 .await?;
424 break;
425 }
426 self.store
427 .advance(
428 registration.config.task,
429 ®istration.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, ®istration.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(®istration.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 ®istration.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 ®istration.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, ®istration.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}