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;