Skip to main content

aion_server/
activity_publisher.rs

1//! NOI-5 transcript sequencer + fan-out — the durability-critical server bridge.
2//!
3//! [`ActivityEventPublisher`] is the aion-server's SEQUENCER for the agent
4//! observability transcript. It is the counterpart of [`crate::cluster_publisher`]'s
5//! `ClusterEventPublisher` for the workflow-agnostic cluster channel, but with one
6//! decisive difference the design calls out explicitly (§5.3): the transcript's
7//! `store_seq` is **NOT a process-local `AtomicU64`**. A per-process counter resets
8//! on restart/failover, so two survivors would mint colliding, non-monotonic
9//! sequences. Instead `store_seq` is **commit-allocated**: the server reads the
10//! durable `O`-keyspace head, appends at that `expected_seq`, and on a
11//! [`StoreError::SequenceConflict`] **re-reads the advanced head and retries**.
12//!
13//! This read-head -> append(expected_seq) -> on-conflict-retry loop
14//! ([`Self::publish`]) is the ONLY thing that keeps `store_seq` monotonic when two
15//! writers race for one `(workflow, run, activity, attempt)` stream (a dying worker +
16//! an adopting worker after failover, or two concurrent publish calls). It is
17//! correctness-critical code, not an implementation detail, and is covered by the
18//! two mandatory NOI-5 negative controls: concurrent-writer monotonicity and
19//! failover dedup.
20//!
21//! # What this does and does not persist
22//!
23//! - **Non-ephemeral events** are durably appended to the `O` keyspace and then
24//!   fanned out to the live transcript broadcast (with the assigned `store_seq`).
25//! - **Ephemeral events** (token deltas) are **WS-forward-only**: fanned out live,
26//!   **never** persisted. They carry `store_seq: None` on the wire, forever.
27//!
28//! # Live fan-out + resume
29//!
30//! Persisted events are also broadcast on a bounded `broadcast::Sender<ActivityEvent>`
31//! so a connected transcript socket tails them live. A reconnecting client resumes
32//! by `store_seq`: [`Self::replay_from`] reads the durable `O` tail from the store,
33//! and [`Self::subscribe`] attaches the live broadcast suppressing any event at or
34//! below the resume cursor (the gap-free splice contract the cluster channel uses).
35
36use std::sync::Arc;
37
38use aion_core::{ActivityEvent, ActivityEventKind, ProgressDetail};
39use aion_store::{ActivityRecord, ActivityStreamKey, ObservabilityStore, StoreError};
40use futures::stream::{self, BoxStream};
41use tokio::sync::broadcast;
42
43use crate::activity_bounds::{TranscriptBounds, bound_event};
44
45/// A lag item on the transcript broadcast: `skipped` events were dropped because
46/// the subscriber fell behind the bounded buffer. Surfaced typed to the client
47/// (which then re-resumes from the durable `O` tail), never a silent skip.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub struct TranscriptStreamLagged {
50    /// Number of transcript events dropped.
51    pub skipped: u64,
52}
53
54impl std::fmt::Display for TranscriptStreamLagged {
55    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        write!(
57            formatter,
58            "transcript stream lagged: {} events dropped",
59            self.skipped
60        )
61    }
62}
63
64impl std::error::Error for TranscriptStreamLagged {}
65
66/// The durable transcript sequencer + live fan-out for one deployment.
67///
68/// Cloneable: the broadcast sender and the store handle are shared, so every
69/// clone sequences into the same `O` keyspace and fans out to the same live
70/// subscribers. The store is the single source of `store_seq` monotonicity; the
71/// broadcast is best-effort live tail only.
72#[derive(Clone)]
73pub struct ActivityEventPublisher {
74    store: Arc<dyn ObservabilityStore>,
75    live: broadcast::Sender<ActivityEvent>,
76    bounds: TranscriptBounds,
77}
78
79impl std::fmt::Debug for ActivityEventPublisher {
80    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        formatter
82            .debug_struct("ActivityEventPublisher")
83            .field("live_receivers", &self.live.receiver_count())
84            .finish_non_exhaustive()
85    }
86}
87
88/// The maximum number of `SequenceConflict` retries before a single `publish`
89/// gives up. In-process publishers serialize per tap (the liminal drain
90/// queue), so conflicts only come from genuine cross-process races — failover
91/// adoption, a dying worker racing its adopter — where a handful of writers
92/// contend. Exceeding this signals a pathological hot loop and must FAIL
93/// CHEAP: the 2026-07-23 flood burned a core spinning 1024-retry loops (each
94/// failed backend append also leaving orphaned store nodes behind), so the
95/// budget is sized to real contention, not to hope.
96const MAX_SEQUENCE_CONFLICT_RETRIES: usize = 16;
97
98impl ActivityEventPublisher {
99    /// Build a publisher over `store` with a live broadcast of `capacity`.
100    ///
101    /// `capacity` is the bounded live-tail buffer; a subscriber that lags beyond
102    /// it receives one typed [`TranscriptStreamLagged`] then re-resumes from the
103    /// durable tail. It must be non-zero (validated by the caller's config).
104    #[must_use]
105    pub fn new(store: Arc<dyn ObservabilityStore>, capacity: std::num::NonZeroUsize) -> Self {
106        let (live, _receiver) = broadcast::channel(capacity.get());
107        Self {
108            store,
109            live,
110            bounds: TranscriptBounds::default(),
111        }
112    }
113
114    /// Replace the default retention bounds with operator-configured ones
115    /// (`[observability]` config). Bounds apply to the durable append path
116    /// only; ephemeral fan-out is untouched.
117    #[must_use]
118    pub(crate) fn with_bounds(mut self, bounds: TranscriptBounds) -> Self {
119        self.bounds = bounds;
120        self
121    }
122
123    /// Sequence + persist + fan out one event.
124    ///
125    /// Ephemeral events are fanned out live with `store_seq: None` and are NEVER
126    /// persisted. Non-ephemeral events are appended to the `O` keyspace under the
127    /// commit-allocated `store_seq` (via the read-head -> `append(expected_seq)` ->
128    /// on-conflict-re-read-head-and-retry loop), then fanned out carrying that
129    /// `store_seq`. Returns the assigned `store_seq` for a persisted event, or
130    /// `None` for an ephemeral one.
131    ///
132    /// A send with no live subscribers is not an error (the calm no-dashboard
133    /// case); the durable append is the primary artifact.
134    ///
135    /// # Errors
136    /// A [`StoreError`] from the durable append (after exhausting the retry
137    /// budget on pathological contention, or any non-conflict backend error).
138    pub async fn publish(&self, event: &ActivityEvent) -> Result<Option<u64>, StoreError> {
139        if event.ephemeral {
140            // WS-forward-only: fan out live with no store_seq, never persist.
141            let mut ephemeral = event.clone();
142            ephemeral.store_seq = None;
143            let send_result = self.live.send(ephemeral);
144            drop(send_result);
145            return Ok(None);
146        }
147
148        // Bound the event FIRST so the persisted record, the live fan-out, and
149        // every later replay all carry the same bounded shape.
150        let event = bound_event(event, self.bounds.max_event_bytes)?;
151        let key = ActivityStreamKey::of(&event);
152        // Seed the optimistic-concurrency loop from the durable head. On a
153        // SequenceConflict a concurrent writer advanced the head between our read
154        // and our append, so we re-read the (now advanced) head and retry — this
155        // is what keeps store_seq strictly monotonic across racing writers.
156        let mut expected_seq = self.store.activity_head(&key).await?;
157        for _attempt in 0..MAX_SEQUENCE_CONFLICT_RETRIES {
158            // The per-stream retention cap is re-evaluated every iteration: a
159            // conflict advances `expected_seq`, which can cross the cap.
160            if expected_seq > self.bounds.max_stream_events {
161                // Past the cap (the marker at the cap seq is already durable):
162                // live streaming continues, persistence stops.
163                self.fan_out_live_only(&event);
164                return Ok(None);
165            }
166            if expected_seq == self.bounds.max_stream_events {
167                match self.append_cap_marker(&event, expected_seq).await {
168                    Ok(()) => {
169                        // The marker is durable; the triggering event itself is
170                        // live-only, like everything after it.
171                        self.fan_out_live_only(&event);
172                        return Ok(None);
173                    }
174                    Err(StoreError::SequenceConflict { found, .. }) => {
175                        // A concurrent writer won the cap seq: adopt the head
176                        // and re-loop (the cap re-check then routes to drop).
177                        expected_seq = found;
178                        continue;
179                    }
180                    Err(error) => return Err(error),
181                }
182            }
183            match self.store.append_activity_event(expected_seq, &event).await {
184                Ok(store_seq) => {
185                    let mut persisted = event.clone();
186                    persisted.store_seq = Some(store_seq);
187                    let send_result = self.live.send(persisted);
188                    drop(send_result);
189                    return Ok(Some(store_seq));
190                }
191                Err(StoreError::SequenceConflict { found, .. }) => {
192                    // The durable head advanced past our expectation: adopt the
193                    // observed head and retry. `found` is the current head, so we
194                    // append there next.
195                    expected_seq = found;
196                }
197                Err(error) => return Err(error),
198            }
199        }
200        Err(StoreError::Backend(format!(
201            "observability append exceeded {MAX_SEQUENCE_CONFLICT_RETRIES} sequence-conflict retries for {key:?}"
202        )))
203    }
204
205    /// Fan one non-ephemeral event out live WITHOUT a `store_seq` (past-cap
206    /// delivery: the event is real transcript, just not retained).
207    fn fan_out_live_only(&self, event: &ActivityEvent) {
208        let mut live_only = event.clone();
209        live_only.store_seq = None;
210        let send_result = self.live.send(live_only);
211        drop(send_result);
212    }
213
214    /// Durably append the one retention-cap marker record at `cap_seq` (the
215    /// stream's `max_stream_events` position) and fan it out with its
216    /// `store_seq`. The marker carries the SAME identity fields as the event
217    /// that crossed the cap, so it lands in the same stream and attributes to
218    /// the same agent.
219    async fn append_cap_marker(
220        &self,
221        event: &ActivityEvent,
222        cap_seq: u64,
223    ) -> Result<(), StoreError> {
224        let cap = self.bounds.max_stream_events;
225        let mut marker = event.clone();
226        marker.kind = ActivityEventKind::Progress {
227            detail: ProgressDetail::Note {
228                text: format!(
229                    "transcript retention cap reached ({cap} events); further events are live-only and not persisted"
230                ),
231            },
232        };
233        let store_seq = self.store.append_activity_event(cap_seq, &marker).await?;
234        marker.store_seq = Some(store_seq);
235        let send_result = self.live.send(marker);
236        drop(send_result);
237        Ok(())
238    }
239
240    /// Read the durable `O` tail for `key` with `store_seq >= from_seq`.
241    ///
242    /// The priming read a resuming transcript client replays before splicing onto
243    /// the live stream. `from_seq = 0` replays the whole persisted transcript.
244    ///
245    /// # Errors
246    /// A [`StoreError`] from the durable read.
247    pub async fn replay_from(
248        &self,
249        key: &ActivityStreamKey,
250        from_seq: u64,
251    ) -> Result<Vec<ActivityRecord>, StoreError> {
252        self.store.read_activity_events_from(key, from_seq).await
253    }
254
255    /// Enumerate the retained transcript streams of ONE RUN of `workflow_id`
256    /// from the durable `O` keyspace (empty for a run with none — old runs
257    /// simply have no retained transcript).
258    ///
259    /// The run is required, never an optional filter: a workflow-wide
260    /// enumeration over a continue-as-new chain would list several generations'
261    /// streams under coordinates that collide pairwise, and the caller could
262    /// not tell them apart.
263    ///
264    /// # Errors
265    /// A [`StoreError`] from the durable enumeration.
266    pub async fn list_streams(
267        &self,
268        workflow_id: &aion_core::WorkflowId,
269        run_id: &aion_core::RunId,
270    ) -> Result<Vec<aion_store::ActivityStreamSummary>, StoreError> {
271        self.store.list_activity_streams(workflow_id, run_id).await
272    }
273
274    /// Subscribe to the live transcript tail for `key`, suppressing every event
275    /// for a DIFFERENT stream and every persisted event already covered by the
276    /// resume cursor.
277    ///
278    /// The broadcast is deployment-wide (one channel), so this filters to `key`'s
279    /// `(workflow, run, activity, attempt)` stream — an event from a sibling
280    /// continue-as-new generation of the same workflow fails the key comparison
281    /// and is suppressed, exactly like a different attempt's.
282    ///
283    /// `after_seq` dedups the splice seam
284    /// exactly like the cluster channel: attach this receiver BEFORE reading the
285    /// priming [`Self::replay_from`] tail, so an event that races the priming read
286    /// is retained by the receiver and applied after it (deduped on `store_seq`).
287    ///
288    /// The cursor is an `Option` because `store_seq` is **0-based** (the first
289    /// event is `store_seq == 0`): `after_seq = None` is a FRESH subscriber that
290    /// has applied nothing and must see every event including `store_seq == 0`;
291    /// `after_seq = Some(n)` has already applied through `store_seq == n`, so
292    /// events with `store_seq <= n` are suppressed at the seam. Ephemeral events
293    /// (which carry `store_seq: None`) for `key` are ALWAYS forwarded live — they
294    /// have no sequence to dedup and are never replayed.
295    #[must_use]
296    pub fn subscribe(
297        &self,
298        key: ActivityStreamKey,
299        after_seq: Option<u64>,
300    ) -> BoxStream<'static, Result<ActivityEvent, TranscriptStreamLagged>> {
301        let receiver = self.live.subscribe();
302        Box::pin(stream::unfold(
303            (receiver, key, after_seq),
304            |(mut receiver, key, after_seq)| async move {
305                loop {
306                    match receiver.recv().await {
307                        Ok(event) => {
308                            if ActivityStreamKey::of(&event) != key {
309                                // A different stream's event on the shared
310                                // broadcast — another attempt, or another
311                                // generation of this same workflow: not for
312                                // this subscriber.
313                                continue;
314                            }
315                            match (event.store_seq, after_seq) {
316                                // Already-applied persisted event at the splice
317                                // seam: suppress it (fall through to re-loop).
318                                (Some(seq), Some(cursor)) if seq <= cursor => {}
319                                // A live persisted event past the cursor, a fresh
320                                // subscriber (no cursor), or an ephemeral (None)
321                                // event: forward it.
322                                _ => return Some((Ok(event), (receiver, key, after_seq))),
323                            }
324                        }
325                        Err(broadcast::error::RecvError::Lagged(skipped)) => {
326                            return Some((
327                                Err(TranscriptStreamLagged { skipped }),
328                                (receiver, key, after_seq),
329                            ));
330                        }
331                        Err(broadcast::error::RecvError::Closed) => return None,
332                    }
333                }
334            },
335        ))
336    }
337}
338
339#[cfg(test)]
340#[path = "activity_publisher_tests.rs"]
341mod tests;