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;