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//!
36//! # Batched commits (write amplification)
37//!
38//! Events do not reach this sequencer one call at a time from the outside world:
39//! they arrive on an in-process queue and a DRAIN task feeds them in
40//! (`ActivityEventPublisher::drain`, used by the liminal observability tap and
41//! by the declared-command executor). Every durable append is one storage-tree
42//! commit, and one commit re-persists the whole containing leaf as a new
43//! permanent blob — so a drain that appended one event per commit made the store
44//! cost of a run linear in EVENT COUNT rather than in event bytes (forensics
45//! 2026-08-17: 2,359 honest bytes of transcript bought a 2,303,416-byte
46//! permanent blob, ~977x; a 136 KB status-sweep run cost 73.7 MB of permanent
47//! store, 540x).
48//!
49//! The drain therefore COALESCES: it takes up to
50//! `TranscriptBatchPolicy::max_batch_events` events off the queue, waiting at
51//! most `TranscriptBatchPolicy::max_hold` for the batch to fill, and appends
52//! each stream's share as ONE commit (`Self::publish_all`). Both values are
53//! operator config with **no default** — see the durability contract below.
54
55use std::sync::Arc;
56use std::time::Duration;
57
58use aion_core::{ActivityEvent, ActivityEventKind, ProgressDetail};
59use aion_store::{ActivityRecord, ActivityStreamKey, ObservabilityStore, StoreError};
60use futures::stream::{self, BoxStream};
61use tokio::sync::{broadcast, mpsc};
62
63use crate::activity_bounds::{TranscriptBounds, bound_event};
64
65/// A lag item on the transcript broadcast: `skipped` events were dropped because
66/// the subscriber fell behind the bounded buffer. Surfaced typed to the client
67/// (which then re-resumes from the durable `O` tail), never a silent skip.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub struct TranscriptStreamLagged {
70 /// Number of transcript events dropped.
71 pub skipped: u64,
72}
73
74impl std::fmt::Display for TranscriptStreamLagged {
75 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 write!(
77 formatter,
78 "transcript stream lagged: {} events dropped",
79 self.skipped
80 )
81 }
82}
83
84impl std::error::Error for TranscriptStreamLagged {}
85
86/// The durable transcript sequencer + live fan-out for one deployment.
87///
88/// Cloneable: the broadcast sender and the store handle are shared, so every
89/// clone sequences into the same `O` keyspace and fans out to the same live
90/// subscribers. The store is the single source of `store_seq` monotonicity; the
91/// broadcast is best-effort live tail only.
92#[derive(Clone)]
93pub struct ActivityEventPublisher {
94 store: Arc<dyn ObservabilityStore>,
95 live: broadcast::Sender<ActivityEvent>,
96 bounds: TranscriptBounds,
97 batch: TranscriptBatchPolicy,
98}
99
100/// The transcript drain's flush policy: how many queued events one durable
101/// commit may carry, and how long the drain may wait to fill that batch.
102///
103/// # There is no default, deliberately
104///
105/// Both values are a deployment trade between store cost and transcript
106/// latency, so neither is invented here: [`crate::config::ObservabilityConfig`]
107/// carries them as `Option`s with no default and the boot path refuses to build
108/// a publisher without them (the `websocket.cluster_broadcast_capacity`
109/// pattern). A server whose operator has not ruled on them does not start, and
110/// says which keys are missing.
111///
112/// # Durability contract
113///
114/// **Unchanged for the caller of [`ActivityEventPublisher::publish`] /
115/// [`ActivityEventPublisher::publish_all`]:** those return only after the
116/// events are durably committed, and an event that has been acked as persisted
117/// (a returned `store_seq`) is exactly as durable as before.
118///
119/// **What `max_hold` extends** is the window in which an event sits in the
120/// server's IN-MEMORY transcript queue before the drain commits it. That window
121/// already existed and is already lossy: the observability tap hands events to a
122/// bounded in-process channel, and everything queued there is lost if the server
123/// process dies. `max_hold` lengthens that pre-existing window by at most its own
124/// value, and `max_batch_events` bounds how many events can be waiting in it
125/// beyond what the channel already held. Nothing that was durable becomes
126/// non-durable; a strictly bounded amount of not-yet-durable transcript stays
127/// not-yet-durable for strictly bounded longer.
128///
129/// That trade is acceptable HERE and would not be elsewhere, because the `O`
130/// keyspace is observability, never replay authority: workflow correctness lives
131/// on the `E`-stream, whose append path is untouched by this policy. Losing the
132/// last few hundred milliseconds of an agent's transcript to a kill-9 costs
133/// transcript, never a workflow decision — which is the same reason the drain
134/// already logs and drops an event the store refuses instead of failing the
135/// activity. An operator who wants the old timing sets `max_hold_ms = 0`: the
136/// drain then commits whatever is already queued the moment it can, coalescing
137/// only what genuinely arrived together, with no added window at all.
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub struct TranscriptBatchPolicy {
140 /// Maximum events in one durable commit (`observability.max_batch_events`).
141 pub max_batch_events: std::num::NonZeroUsize,
142 /// Maximum time the drain waits for a partial batch to fill
143 /// (`observability.max_batch_hold_ms`). Zero means "never wait".
144 pub max_hold: Duration,
145}
146
147impl TranscriptBatchPolicy {
148 /// The IDENTITY policy: one event per durable commit, never held.
149 ///
150 /// This is the pre-batching behaviour, and the only policy that assumes
151 /// nothing about a deployment — which is exactly why it is the fallback for
152 /// the embedder/test state constructors that bypass config validation and
153 /// cannot refuse to build, and the policy under which the single-event
154 /// sequencing tests run. A config-driven server never reaches it: it states
155 /// its own policy or it does not start.
156 pub const UNBATCHED: Self = Self {
157 max_batch_events: std::num::NonZeroUsize::MIN,
158 max_hold: Duration::ZERO,
159 };
160}
161
162impl std::fmt::Debug for ActivityEventPublisher {
163 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 formatter
165 .debug_struct("ActivityEventPublisher")
166 .field("live_receivers", &self.live.receiver_count())
167 .finish_non_exhaustive()
168 }
169}
170
171/// The maximum number of `SequenceConflict` retries before a single `publish`
172/// gives up. In-process publishers serialize per tap (the liminal drain
173/// queue), so conflicts only come from genuine cross-process races — failover
174/// adoption, a dying worker racing its adopter — where a handful of writers
175/// contend. Exceeding this signals a pathological hot loop and must FAIL
176/// CHEAP: the 2026-07-23 flood burned a core spinning 1024-retry loops (each
177/// failed backend append also leaving orphaned store nodes behind), so the
178/// budget is sized to real contention, not to hope.
179const MAX_SEQUENCE_CONFLICT_RETRIES: usize = 16;
180
181impl ActivityEventPublisher {
182 /// Build a publisher over `store` with a live broadcast of `capacity` and
183 /// the drain's `batch` flush policy.
184 ///
185 /// `capacity` is the bounded live-tail buffer; a subscriber that lags beyond
186 /// it receives one typed [`TranscriptStreamLagged`] then re-resumes from the
187 /// durable tail. It must be non-zero (validated by the caller's config).
188 ///
189 /// `batch` is a REQUIRED argument, not an option with a fallback: the flush
190 /// policy is the operator's ruling on store cost against transcript latency
191 /// and this type refuses to invent one (see [`TranscriptBatchPolicy`]).
192 #[must_use]
193 pub fn new(
194 store: Arc<dyn ObservabilityStore>,
195 capacity: std::num::NonZeroUsize,
196 batch: TranscriptBatchPolicy,
197 ) -> Self {
198 let (live, _receiver) = broadcast::channel(capacity.get());
199 Self {
200 store,
201 live,
202 bounds: TranscriptBounds::default(),
203 batch,
204 }
205 }
206
207 /// Replace the default retention bounds with operator-configured ones
208 /// (`[observability]` config). Bounds apply to the durable append path
209 /// only; ephemeral fan-out is untouched.
210 #[must_use]
211 pub(crate) fn with_bounds(mut self, bounds: TranscriptBounds) -> Self {
212 self.bounds = bounds;
213 self
214 }
215
216 /// Sequence + persist + fan out one event.
217 ///
218 /// Ephemeral events are fanned out live with `store_seq: None` and are NEVER
219 /// persisted. Non-ephemeral events are appended to the `O` keyspace under the
220 /// commit-allocated `store_seq` (via the read-head -> `append(expected_seq)` ->
221 /// on-conflict-re-read-head-and-retry loop), then fanned out carrying that
222 /// `store_seq`. Returns the assigned `store_seq` for a persisted event, or
223 /// `None` for an ephemeral one.
224 ///
225 /// A send with no live subscribers is not an error (the calm no-dashboard
226 /// case); the durable append is the primary artifact.
227 ///
228 /// # Errors
229 /// A [`StoreError`] from the durable append (after exhausting the retry
230 /// budget on pathological contention, or any non-conflict backend error).
231 pub async fn publish(&self, event: &ActivityEvent) -> Result<Option<u64>, StoreError> {
232 let assigned = self.publish_all(std::slice::from_ref(event)).await?;
233 // `publish_all` returns one entry per input event, in input order.
234 Ok(assigned.into_iter().next().flatten())
235 }
236
237 /// Sequence + persist + fan out a batch of events as FEW durable commits as
238 /// the batch allows: one commit per distinct
239 /// `(workflow, run, activity, attempt)` stream present in `events`.
240 ///
241 /// Returns one entry per input event, in input order: the assigned
242 /// `store_seq` for a persisted event, or `None` for an ephemeral one and for
243 /// one past the stream's retention cap — the same per-event answer
244 /// [`Self::publish`] gives, because `publish` IS this method over a slice of
245 /// one.
246 ///
247 /// Events keep their arrival order within a stream, and a stream's whole
248 /// share of the batch is committed at one `expected_seq` under the same
249 /// read-head -> append -> on-conflict-re-read-head-and-retry loop the
250 /// single-event path used, so `store_seq` monotonicity and the single-writer
251 /// law are untouched: a `SequenceConflict` leaves the WHOLE batch unwritten
252 /// (the store contract is all-or-nothing) and the batch is retried at the
253 /// observed head.
254 ///
255 /// # Errors
256 /// A [`StoreError`] from a durable append. Streams are independent, so a
257 /// failure on one stream does not abandon the others: every stream in the
258 /// batch is attempted and the FIRST error is returned once they have all
259 /// been tried. (A caller with a single-stream batch — every drain call in
260 /// this server — sees exactly the single-event behaviour.)
261 pub async fn publish_all(
262 &self,
263 events: &[ActivityEvent],
264 ) -> Result<Vec<Option<u64>>, StoreError> {
265 let (outcomes, first_error) = self.publish_all_outcomes(events).await;
266 match first_error {
267 Some(error) => Err(error),
268 None => Ok(outcomes
269 .into_iter()
270 .map(|outcome| match outcome {
271 EventOutcome::Persisted(store_seq) => Some(store_seq),
272 EventOutcome::NotPersisted | EventOutcome::Refused => None,
273 })
274 .collect()),
275 }
276 }
277
278 /// [`Self::publish_all`] with the per-event outcome kept even when a stream
279 /// failed, so the drain can count EXACTLY how many events the store refused
280 /// rather than reporting an upper bound.
281 async fn publish_all_outcomes(
282 &self,
283 events: &[ActivityEvent],
284 ) -> (Vec<EventOutcome>, Option<StoreError>) {
285 let mut outcomes: Vec<EventOutcome> = vec![EventOutcome::NotPersisted; events.len()];
286 let mut first_error: Option<StoreError> = None;
287 // Group the durable events by stream key, preserving arrival order both
288 // between groups (first-seen key first) and inside each one.
289 let mut groups: Vec<(ActivityStreamKey, Vec<(usize, ActivityEvent)>)> = Vec::new();
290 for (index, event) in events.iter().enumerate() {
291 if event.ephemeral {
292 // WS-forward-only: fan out live with no store_seq, never persist.
293 let mut ephemeral = event.clone();
294 ephemeral.store_seq = None;
295 let send_result = self.live.send(ephemeral);
296 drop(send_result);
297 continue;
298 }
299 // Bound the event FIRST so the persisted record, the live fan-out,
300 // and every later replay all carry the same bounded shape. A single
301 // unrepresentable event is that event's failure, not the batch's.
302 let bounded = match bound_event(event, self.bounds.max_event_bytes) {
303 Ok(bounded) => bounded,
304 Err(error) => {
305 outcomes[index] = EventOutcome::Refused;
306 if first_error.is_none() {
307 first_error = Some(error);
308 }
309 continue;
310 }
311 };
312 let key = ActivityStreamKey::of(&bounded);
313 match groups.iter_mut().find(|(existing, _)| *existing == key) {
314 Some((_, items)) => items.push((index, bounded)),
315 None => groups.push((key, vec![(index, bounded)])),
316 }
317 }
318 for (key, items) in groups {
319 if let Err(error) = self.persist_group(&key, &items, &mut outcomes).await
320 && first_error.is_none()
321 {
322 first_error = Some(error);
323 }
324 }
325 (outcomes, first_error)
326 }
327
328 /// Commit one stream's share of a batch, honouring the retention cap and the
329 /// optimistic-concurrency retry budget.
330 ///
331 /// `items` are `(index into the caller's batch, bounded event)` pairs in
332 /// arrival order, all for `key`; each event's outcome is written back into
333 /// `outcomes` at its index — its assigned `store_seq` when persisted,
334 /// [`EventOutcome::Refused`] when the store would not take it. A batch that
335 /// would cross the per-stream cap is split at the cap: the events below it
336 /// commit, the cap marker is written at the cap sequence, and the remainder
337 /// is fanned out live-only — exactly the sequence of states the single-event
338 /// path produced.
339 async fn persist_group(
340 &self,
341 key: &ActivityStreamKey,
342 items: &[(usize, ActivityEvent)],
343 outcomes: &mut [EventOutcome],
344 ) -> Result<(), StoreError> {
345 // Seed the optimistic-concurrency loop from the durable head. On a
346 // SequenceConflict a concurrent writer advanced the head between our read
347 // and our append, so we re-read the (now advanced) head and retry — this
348 // is what keeps store_seq strictly monotonic across racing writers.
349 let mut expected_seq = match self.store.activity_head(key).await {
350 Ok(head) => head,
351 Err(error) => {
352 mark_refused(items, outcomes, 0);
353 return Err(error);
354 }
355 };
356 let mut committed = 0usize;
357 let mut conflicts = 0usize;
358 while committed < items.len() {
359 // The per-stream retention cap is re-evaluated every iteration: a
360 // conflict advances `expected_seq`, which can cross the cap.
361 if expected_seq > self.bounds.max_stream_events {
362 // Past the cap (the marker at the cap seq is already durable):
363 // live streaming continues, persistence stops.
364 for (_, event) in &items[committed..] {
365 self.fan_out_live_only(event);
366 }
367 return Ok(());
368 }
369 if expected_seq == self.bounds.max_stream_events {
370 match self
371 .append_cap_marker(&items[committed].1, expected_seq)
372 .await
373 {
374 Ok(()) => {
375 // The marker is durable; the triggering events themselves
376 // are live-only, like everything after them.
377 for (_, event) in &items[committed..] {
378 self.fan_out_live_only(event);
379 }
380 return Ok(());
381 }
382 Err(StoreError::SequenceConflict { found, .. }) => {
383 // A concurrent writer won the cap seq: adopt the head
384 // and re-loop (the cap re-check then routes to drop).
385 expected_seq = found;
386 conflicts += 1;
387 }
388 Err(error) => {
389 mark_refused(items, outcomes, committed);
390 return Err(error);
391 }
392 }
393 if conflicts >= MAX_SEQUENCE_CONFLICT_RETRIES {
394 break;
395 }
396 continue;
397 }
398 // Commit only what fits below the cap; the remainder re-enters the
399 // loop at `expected_seq == cap` and takes the marker arm.
400 let room = usize::try_from(self.bounds.max_stream_events - expected_seq)
401 .unwrap_or(usize::MAX)
402 .min(items.len() - committed);
403 let batch: Vec<ActivityEvent> = items[committed..committed + room]
404 .iter()
405 .map(|(_, event)| event.clone())
406 .collect();
407 match self
408 .store
409 .append_activity_events(expected_seq, &batch)
410 .await
411 {
412 Ok(_new_head) => {
413 for (offset, (index, event)) in
414 items[committed..committed + room].iter().enumerate()
415 {
416 let store_seq =
417 expected_seq.saturating_add(u64::try_from(offset).unwrap_or(u64::MAX));
418 outcomes[*index] = EventOutcome::Persisted(store_seq);
419 let mut persisted = event.clone();
420 persisted.store_seq = Some(store_seq);
421 let send_result = self.live.send(persisted);
422 drop(send_result);
423 }
424 committed += room;
425 expected_seq =
426 expected_seq.saturating_add(u64::try_from(room).unwrap_or(u64::MAX));
427 }
428 Err(StoreError::SequenceConflict { found, .. }) => {
429 // The durable head advanced past our expectation: adopt the
430 // observed head and retry this batch there. Nothing of the
431 // batch was written (all-or-nothing store contract).
432 expected_seq = found;
433 conflicts += 1;
434 if conflicts >= MAX_SEQUENCE_CONFLICT_RETRIES {
435 break;
436 }
437 }
438 Err(error) => {
439 mark_refused(items, outcomes, committed);
440 return Err(error);
441 }
442 }
443 }
444 if committed < items.len() {
445 mark_refused(items, outcomes, committed);
446 return Err(StoreError::Backend(format!(
447 "observability append exceeded {MAX_SEQUENCE_CONFLICT_RETRIES} sequence-conflict retries for {key:?}"
448 )));
449 }
450 Ok(())
451 }
452
453 /// Drain `receiver` into the durable transcript in COALESCED batches until
454 /// the seam closes, returning the number of events the store refused.
455 ///
456 /// This is the one transcript drain: the liminal observability tap and the
457 /// declared-command executor both run their queue through it, so both pay
458 /// one commit per batch rather than one per event. Each iteration takes up
459 /// to [`TranscriptBatchPolicy::max_batch_events`] events off the queue,
460 /// waits at most [`TranscriptBatchPolicy::max_hold`] for a partial batch to
461 /// fill (skipping the wait entirely when the hold is zero or the batch is
462 /// already full), and commits the batch with [`Self::publish_all`].
463 ///
464 /// Returns when the sender is dropped AND the queue is empty, so a caller
465 /// can await this and know every queued event has been offered to the
466 /// sequencer.
467 ///
468 /// # Losing a transcript never fails the producer
469 ///
470 /// A store that refuses a batch costs a log line and that batch's events,
471 /// never the activity or the command: the refusal is warned once per FAILED
472 /// BATCH (bounded by the flush policy — never one log line per line of
473 /// output) naming `operation` and the batch's first event, and the total is
474 /// returned for the caller's end-of-run summary.
475 pub(crate) async fn drain<R: TranscriptEventReceiver>(
476 &self,
477 receiver: &mut R,
478 operation: &'static str,
479 ) -> u64 {
480 let limit = self.batch.max_batch_events.get();
481 let mut buffer: Vec<ActivityEvent> = Vec::with_capacity(limit);
482 let mut dropped: u64 = 0;
483 loop {
484 let mut closed = receiver.recv_many(&mut buffer, limit).await == 0;
485 if !closed && buffer.len() < limit && !self.batch.max_hold.is_zero() {
486 // Hold the partial batch open briefly so events arriving in the
487 // same burst share one commit. The deadline is absolute, so a
488 // slow trickle cannot extend the window indefinitely.
489 let deadline = tokio::time::Instant::now() + self.batch.max_hold;
490 while buffer.len() < limit {
491 let remaining = limit - buffer.len();
492 match tokio::time::timeout_at(
493 deadline,
494 receiver.recv_many(&mut buffer, remaining),
495 )
496 .await
497 {
498 Ok(0) => {
499 closed = true;
500 break;
501 }
502 Ok(_) => {}
503 Err(_elapsed) => break,
504 }
505 }
506 }
507 if !buffer.is_empty() {
508 let (outcomes, error) = self.publish_all_outcomes(&buffer).await;
509 if let Some(error) = error {
510 let refused = outcomes
511 .iter()
512 .filter(|outcome| matches!(outcome, EventOutcome::Refused))
513 .count();
514 dropped = dropped.saturating_add(u64::try_from(refused).unwrap_or(u64::MAX));
515 let first = buffer.first();
516 tracing::warn!(
517 %error,
518 operation,
519 batch_events = buffer.len(),
520 refused_events = refused,
521 workflow_id = ?first.map(|event| event.workflow_id.to_string()),
522 activity_id = ?first.map(|event| event.activity_id.to_string()),
523 attempt = ?first.map(|event| event.attempt),
524 "transcript drain: the sequencer refused part of a batch; the producer \
525 is unaffected and the refused events are not retained"
526 );
527 }
528 buffer.clear();
529 }
530 if closed {
531 return dropped;
532 }
533 }
534 }
535
536 /// Fan one non-ephemeral event out live WITHOUT a `store_seq` (past-cap
537 /// delivery: the event is real transcript, just not retained).
538 fn fan_out_live_only(&self, event: &ActivityEvent) {
539 let mut live_only = event.clone();
540 live_only.store_seq = None;
541 let send_result = self.live.send(live_only);
542 drop(send_result);
543 }
544
545 /// Durably append the one retention-cap marker record at `cap_seq` (the
546 /// stream's `max_stream_events` position) and fan it out with its
547 /// `store_seq`. The marker carries the SAME identity fields as the event
548 /// that crossed the cap, so it lands in the same stream and attributes to
549 /// the same agent.
550 async fn append_cap_marker(
551 &self,
552 event: &ActivityEvent,
553 cap_seq: u64,
554 ) -> Result<(), StoreError> {
555 let cap = self.bounds.max_stream_events;
556 let mut marker = event.clone();
557 marker.kind = ActivityEventKind::Progress {
558 detail: ProgressDetail::Note {
559 text: format!(
560 "transcript retention cap reached ({cap} events); further events are live-only and not persisted"
561 ),
562 },
563 };
564 let store_seq = self.store.append_activity_event(cap_seq, &marker).await?;
565 marker.store_seq = Some(store_seq);
566 let send_result = self.live.send(marker);
567 drop(send_result);
568 Ok(())
569 }
570
571 /// Read the durable `O` tail for `key` with `store_seq >= from_seq`.
572 ///
573 /// The priming read a resuming transcript client replays before splicing onto
574 /// the live stream. `from_seq = 0` replays the whole persisted transcript.
575 ///
576 /// # Errors
577 /// A [`StoreError`] from the durable read.
578 pub async fn replay_from(
579 &self,
580 key: &ActivityStreamKey,
581 from_seq: u64,
582 ) -> Result<Vec<ActivityRecord>, StoreError> {
583 self.store.read_activity_events_from(key, from_seq).await
584 }
585
586 /// Enumerate the retained transcript streams of ONE RUN of `workflow_id`
587 /// from the durable `O` keyspace (empty for a run with none — old runs
588 /// simply have no retained transcript).
589 ///
590 /// The run is required, never an optional filter: a workflow-wide
591 /// enumeration over a continue-as-new chain would list several generations'
592 /// streams under coordinates that collide pairwise, and the caller could
593 /// not tell them apart.
594 ///
595 /// # Errors
596 /// A [`StoreError`] from the durable enumeration.
597 pub async fn list_streams(
598 &self,
599 workflow_id: &aion_core::WorkflowId,
600 run_id: &aion_core::RunId,
601 ) -> Result<Vec<aion_store::ActivityStreamSummary>, StoreError> {
602 self.store.list_activity_streams(workflow_id, run_id).await
603 }
604
605 /// Subscribe to the live transcript tail for `key`, suppressing every event
606 /// for a DIFFERENT stream and every persisted event already covered by the
607 /// resume cursor.
608 ///
609 /// The broadcast is deployment-wide (one channel), so this filters to `key`'s
610 /// `(workflow, run, activity, attempt)` stream — an event from a sibling
611 /// continue-as-new generation of the same workflow fails the key comparison
612 /// and is suppressed, exactly like a different attempt's.
613 ///
614 /// `after_seq` dedups the splice seam
615 /// exactly like the cluster channel: attach this receiver BEFORE reading the
616 /// priming [`Self::replay_from`] tail, so an event that races the priming read
617 /// is retained by the receiver and applied after it (deduped on `store_seq`).
618 ///
619 /// The cursor is an `Option` because `store_seq` is **0-based** (the first
620 /// event is `store_seq == 0`): `after_seq = None` is a FRESH subscriber that
621 /// has applied nothing and must see every event including `store_seq == 0`;
622 /// `after_seq = Some(n)` has already applied through `store_seq == n`, so
623 /// events with `store_seq <= n` are suppressed at the seam. Ephemeral events
624 /// (which carry `store_seq: None`) for `key` are ALWAYS forwarded live — they
625 /// have no sequence to dedup and are never replayed.
626 #[must_use]
627 pub fn subscribe(
628 &self,
629 key: ActivityStreamKey,
630 after_seq: Option<u64>,
631 ) -> BoxStream<'static, Result<ActivityEvent, TranscriptStreamLagged>> {
632 let receiver = self.live.subscribe();
633 Box::pin(stream::unfold(
634 (receiver, key, after_seq),
635 |(mut receiver, key, after_seq)| async move {
636 loop {
637 match receiver.recv().await {
638 Ok(event) => {
639 if ActivityStreamKey::of(&event) != key {
640 // A different stream's event on the shared
641 // broadcast — another attempt, or another
642 // generation of this same workflow: not for
643 // this subscriber.
644 continue;
645 }
646 match (event.store_seq, after_seq) {
647 // Already-applied persisted event at the splice
648 // seam: suppress it (fall through to re-loop).
649 (Some(seq), Some(cursor)) if seq <= cursor => {}
650 // A live persisted event past the cursor, a fresh
651 // subscriber (no cursor), or an ephemeral (None)
652 // event: forward it.
653 _ => return Some((Ok(event), (receiver, key, after_seq))),
654 }
655 }
656 Err(broadcast::error::RecvError::Lagged(skipped)) => {
657 return Some((
658 Err(TranscriptStreamLagged { skipped }),
659 (receiver, key, after_seq),
660 ));
661 }
662 Err(broadcast::error::RecvError::Closed) => return None,
663 }
664 }
665 },
666 ))
667 }
668}
669
670/// What became of one event handed to [`ActivityEventPublisher::publish_all`].
671///
672/// Distinguishing "not persisted by design" from "the store refused it" is what
673/// lets the drain report an EXACT dropped count instead of an upper bound: an
674/// ephemeral or past-the-retention-cap event is `NotPersisted` and is not a
675/// loss, while a `Refused` event is transcript the operator asked for and did
676/// not get.
677#[derive(Clone, Copy, Debug, PartialEq, Eq)]
678enum EventOutcome {
679 /// Deliberately not written: an ephemeral event, or one past the stream's
680 /// retention cap. Live fan-out still happened.
681 NotPersisted,
682 /// Durably committed at this `store_seq`.
683 Persisted(u64),
684 /// The store would not take it — the only outcome that counts as a loss.
685 Refused,
686}
687
688/// Mark every item of a group from `from` onwards as refused by the store.
689fn mark_refused(items: &[(usize, ActivityEvent)], outcomes: &mut [EventOutcome], from: usize) {
690 for (index, _) in &items[from..] {
691 outcomes[*index] = EventOutcome::Refused;
692 }
693}
694
695/// A queue [`ActivityEventPublisher::drain`] can pull whole batches from.
696///
697/// Exists so the ONE drain implementation serves both transcript seams — the
698/// liminal observability tap's bounded channel and the declared-command
699/// executor's unbounded one — without either growing its own copy of the
700/// coalescing loop. Both tokio receivers already offer the batch primitive;
701/// this trait is the two-line adapter that lets one loop take either.
702#[async_trait::async_trait]
703pub(crate) trait TranscriptEventReceiver: Send {
704 /// Move up to `limit` currently-queued events into `buffer`, awaiting the
705 /// first one, and return how many were moved. Returns `0` ONLY when the
706 /// channel is closed and drained (`limit` is never zero here).
707 async fn recv_many(&mut self, buffer: &mut Vec<ActivityEvent>, limit: usize) -> usize;
708}
709
710#[async_trait::async_trait]
711impl TranscriptEventReceiver for mpsc::Receiver<ActivityEvent> {
712 async fn recv_many(&mut self, buffer: &mut Vec<ActivityEvent>, limit: usize) -> usize {
713 Self::recv_many(self, buffer, limit).await
714 }
715}
716
717#[async_trait::async_trait]
718impl TranscriptEventReceiver for mpsc::UnboundedReceiver<ActivityEvent> {
719 async fn recv_many(&mut self, buffer: &mut Vec<ActivityEvent>, limit: usize) -> usize {
720 Self::recv_many(self, buffer, limit).await
721 }
722}
723
724#[cfg(test)]
725#[path = "activity_publisher_tests.rs"]
726mod tests;
727
728#[cfg(test)]
729#[path = "activity_publisher_batching_tests.rs"]
730mod batching_tests;