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, 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. Under real single-deployment contention only a handful of writers
90/// ever race one stream, so the bound is generous; exceeding it signals a
91/// pathological hot loop rather than normal contention and is surfaced as an
92/// error rather than spun on forever.
93const MAX_SEQUENCE_CONFLICT_RETRIES: usize = 1024;
94
95impl ActivityEventPublisher {
96    /// Build a publisher over `store` with a live broadcast of `capacity`.
97    ///
98    /// `capacity` is the bounded live-tail buffer; a subscriber that lags beyond
99    /// it receives one typed [`TranscriptStreamLagged`] then re-resumes from the
100    /// durable tail. It must be non-zero (validated by the caller's config).
101    #[must_use]
102    pub fn new(store: Arc<dyn ObservabilityStore>, capacity: std::num::NonZeroUsize) -> Self {
103        let (live, _receiver) = broadcast::channel(capacity.get());
104        Self {
105            store,
106            live,
107            bounds: TranscriptBounds::default(),
108        }
109    }
110
111    /// Replace the default retention bounds with operator-configured ones
112    /// (`[observability]` config). Bounds apply to the durable append path
113    /// only; ephemeral fan-out is untouched.
114    #[must_use]
115    pub(crate) fn with_bounds(mut self, bounds: TranscriptBounds) -> Self {
116        self.bounds = bounds;
117        self
118    }
119
120    /// Sequence + persist + fan out one event.
121    ///
122    /// Ephemeral events are fanned out live with `store_seq: None` and are NEVER
123    /// persisted. Non-ephemeral events are appended to the `O` keyspace under the
124    /// commit-allocated `store_seq` (via the read-head -> `append(expected_seq)` ->
125    /// on-conflict-re-read-head-and-retry loop), then fanned out carrying that
126    /// `store_seq`. Returns the assigned `store_seq` for a persisted event, or
127    /// `None` for an ephemeral one.
128    ///
129    /// A send with no live subscribers is not an error (the calm no-dashboard
130    /// case); the durable append is the primary artifact.
131    ///
132    /// # Errors
133    /// A [`StoreError`] from the durable append (after exhausting the retry
134    /// budget on pathological contention, or any non-conflict backend error).
135    pub async fn publish(&self, event: &ActivityEvent) -> Result<Option<u64>, StoreError> {
136        if event.ephemeral {
137            // WS-forward-only: fan out live with no store_seq, never persist.
138            let mut ephemeral = event.clone();
139            ephemeral.store_seq = None;
140            let send_result = self.live.send(ephemeral);
141            drop(send_result);
142            return Ok(None);
143        }
144
145        // Bound the event FIRST so the persisted record, the live fan-out, and
146        // every later replay all carry the same bounded shape.
147        let event = bound_event(event, self.bounds.max_event_bytes)?;
148        let key = ActivityStreamKey::of(&event);
149        // Seed the optimistic-concurrency loop from the durable head. On a
150        // SequenceConflict a concurrent writer advanced the head between our read
151        // and our append, so we re-read the (now advanced) head and retry — this
152        // is what keeps store_seq strictly monotonic across racing writers.
153        let mut expected_seq = self.store.activity_head(&key).await?;
154        for _attempt in 0..MAX_SEQUENCE_CONFLICT_RETRIES {
155            // The per-stream retention cap is re-evaluated every iteration: a
156            // conflict advances `expected_seq`, which can cross the cap.
157            if expected_seq > self.bounds.max_stream_events {
158                // Past the cap (the marker at the cap seq is already durable):
159                // live streaming continues, persistence stops.
160                self.fan_out_live_only(&event);
161                return Ok(None);
162            }
163            if expected_seq == self.bounds.max_stream_events {
164                match self.append_cap_marker(&event, expected_seq).await {
165                    Ok(()) => {
166                        // The marker is durable; the triggering event itself is
167                        // live-only, like everything after it.
168                        self.fan_out_live_only(&event);
169                        return Ok(None);
170                    }
171                    Err(StoreError::SequenceConflict { found, .. }) => {
172                        // A concurrent writer won the cap seq: adopt the head
173                        // and re-loop (the cap re-check then routes to drop).
174                        expected_seq = found;
175                        continue;
176                    }
177                    Err(error) => return Err(error),
178                }
179            }
180            match self.store.append_activity_event(expected_seq, &event).await {
181                Ok(store_seq) => {
182                    let mut persisted = event.clone();
183                    persisted.store_seq = Some(store_seq);
184                    let send_result = self.live.send(persisted);
185                    drop(send_result);
186                    return Ok(Some(store_seq));
187                }
188                Err(StoreError::SequenceConflict { found, .. }) => {
189                    // The durable head advanced past our expectation: adopt the
190                    // observed head and retry. `found` is the current head, so we
191                    // append there next.
192                    expected_seq = found;
193                }
194                Err(error) => return Err(error),
195            }
196        }
197        Err(StoreError::Backend(format!(
198            "observability append exceeded {MAX_SEQUENCE_CONFLICT_RETRIES} sequence-conflict retries for {key:?}"
199        )))
200    }
201
202    /// Fan one non-ephemeral event out live WITHOUT a `store_seq` (past-cap
203    /// delivery: the event is real transcript, just not retained).
204    fn fan_out_live_only(&self, event: &ActivityEvent) {
205        let mut live_only = event.clone();
206        live_only.store_seq = None;
207        let send_result = self.live.send(live_only);
208        drop(send_result);
209    }
210
211    /// Durably append the one retention-cap marker record at `cap_seq` (the
212    /// stream's `max_stream_events` position) and fan it out with its
213    /// `store_seq`. The marker carries the SAME identity fields as the event
214    /// that crossed the cap, so it lands in the same stream and attributes to
215    /// the same agent.
216    async fn append_cap_marker(
217        &self,
218        event: &ActivityEvent,
219        cap_seq: u64,
220    ) -> Result<(), StoreError> {
221        let cap = self.bounds.max_stream_events;
222        let mut marker = event.clone();
223        marker.kind = ActivityEventKind::Progress {
224            detail: ProgressDetail::Note {
225                text: format!(
226                    "transcript retention cap reached ({cap} events); further events are live-only and not persisted"
227                ),
228            },
229        };
230        let store_seq = self.store.append_activity_event(cap_seq, &marker).await?;
231        marker.store_seq = Some(store_seq);
232        let send_result = self.live.send(marker);
233        drop(send_result);
234        Ok(())
235    }
236
237    /// Read the durable `O` tail for `key` with `store_seq >= from_seq`.
238    ///
239    /// The priming read a resuming transcript client replays before splicing onto
240    /// the live stream. `from_seq = 0` replays the whole persisted transcript.
241    ///
242    /// # Errors
243    /// A [`StoreError`] from the durable read.
244    pub async fn replay_from(
245        &self,
246        key: &ActivityStreamKey,
247        from_seq: u64,
248    ) -> Result<Vec<ActivityRecord>, StoreError> {
249        self.store.read_activity_events_from(key, from_seq).await
250    }
251
252    /// Enumerate the retained transcript streams of `workflow_id` from the
253    /// durable `O` keyspace (empty for a workflow with none — old runs simply
254    /// have no retained transcript).
255    ///
256    /// # Errors
257    /// A [`StoreError`] from the durable enumeration.
258    pub async fn list_streams(
259        &self,
260        workflow_id: &aion_core::WorkflowId,
261    ) -> Result<Vec<aion_store::ActivityStreamSummary>, StoreError> {
262        self.store.list_activity_streams(workflow_id).await
263    }
264
265    /// Subscribe to the live transcript tail for `key`, suppressing every event
266    /// for a DIFFERENT stream and every persisted event already covered by the
267    /// resume cursor.
268    ///
269    /// The broadcast is deployment-wide (one channel), so this filters to `key`'s
270    /// `(workflow, activity, attempt)` stream. `after_seq` dedups the splice seam
271    /// exactly like the cluster channel: attach this receiver BEFORE reading the
272    /// priming [`Self::replay_from`] tail, so an event that races the priming read
273    /// is retained by the receiver and applied after it (deduped on `store_seq`).
274    ///
275    /// The cursor is an `Option` because `store_seq` is **0-based** (the first
276    /// event is `store_seq == 0`): `after_seq = None` is a FRESH subscriber that
277    /// has applied nothing and must see every event including `store_seq == 0`;
278    /// `after_seq = Some(n)` has already applied through `store_seq == n`, so
279    /// events with `store_seq <= n` are suppressed at the seam. Ephemeral events
280    /// (which carry `store_seq: None`) for `key` are ALWAYS forwarded live — they
281    /// have no sequence to dedup and are never replayed.
282    #[must_use]
283    pub fn subscribe(
284        &self,
285        key: ActivityStreamKey,
286        after_seq: Option<u64>,
287    ) -> BoxStream<'static, Result<ActivityEvent, TranscriptStreamLagged>> {
288        let receiver = self.live.subscribe();
289        Box::pin(stream::unfold(
290            (receiver, key, after_seq),
291            |(mut receiver, key, after_seq)| async move {
292                loop {
293                    match receiver.recv().await {
294                        Ok(event) => {
295                            if ActivityStreamKey::of(&event) != key {
296                                // A different attempt's event on the shared
297                                // broadcast: not for this subscriber.
298                                continue;
299                            }
300                            match (event.store_seq, after_seq) {
301                                // Already-applied persisted event at the splice
302                                // seam: suppress it (fall through to re-loop).
303                                (Some(seq), Some(cursor)) if seq <= cursor => {}
304                                // A live persisted event past the cursor, a fresh
305                                // subscriber (no cursor), or an ephemeral (None)
306                                // event: forward it.
307                                _ => return Some((Ok(event), (receiver, key, after_seq))),
308                            }
309                        }
310                        Err(broadcast::error::RecvError::Lagged(skipped)) => {
311                            return Some((
312                                Err(TranscriptStreamLagged { skipped }),
313                                (receiver, key, after_seq),
314                            ));
315                        }
316                        Err(broadcast::error::RecvError::Closed) => return None,
317                    }
318                }
319            },
320        ))
321    }
322}
323
324#[cfg(test)]
325#[path = "activity_publisher_tests.rs"]
326mod tests;