1use std::collections::{HashMap, VecDeque};
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9
10use parking_lot::Mutex;
11use tokio::sync::mpsc;
12
13use crate::owned_effect::OwnedEffect;
14use crate::trace::TraceCarrier;
15use helix_core::effect::{Correlation, TimerId};
16use helix_core::Tick;
17
18pub type TickId = u64;
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23pub enum LifecycleStage {
24 T1,
25 T2,
26 T3,
27 T4,
28 T5,
29}
30
31impl LifecycleStage {
32 pub const fn as_str(self) -> &'static str {
34 match self {
35 Self::T1 => "T1",
36 Self::T2 => "T2",
37 Self::T3 => "T3",
38 Self::T4 => "T4",
39 Self::T5 => "T5",
40 }
41 }
42
43 const fn index(self) -> usize {
45 match self {
46 Self::T1 => 0,
47 Self::T2 => 1,
48 Self::T3 => 2,
49 Self::T4 => 3,
50 Self::T5 => 4,
51 }
52 }
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
57pub enum LifecycleCapability {
58 Http,
59 Ws,
60 Persist,
61 Effect,
62}
63
64impl LifecycleCapability {
65 pub const fn as_str(self) -> &'static str {
67 match self {
68 Self::Http => "http",
69 Self::Ws => "ws",
70 Self::Persist => "persist",
71 Self::Effect => "effect",
72 }
73 }
74
75 const fn index(self) -> usize {
77 match self {
78 Self::Http => 0,
79 Self::Ws => 1,
80 Self::Persist => 2,
81 Self::Effect => 3,
82 }
83 }
84}
85
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
88pub enum LifecycleStatus {
89 Pending,
90 Started,
91 Ok,
92 Error,
93 Skipped,
94 NotApplicable,
95}
96
97impl LifecycleStatus {
98 pub const fn as_str(self) -> &'static str {
100 match self {
101 Self::Pending => "pending",
102 Self::Started => "started",
103 Self::Ok => "ok",
104 Self::Error => "error",
105 Self::Skipped => "skipped",
106 Self::NotApplicable => "not_applicable",
107 }
108 }
109}
110
111pub type LifecycleState = LifecycleStatus;
113
114#[derive(Clone, Debug, PartialEq, Eq)]
120pub struct LifecycleContext {
121 tick_id: TickId,
122 parent_tick_id: Option<TickId>,
123 carrier: Option<TraceCarrier>,
124 span_parent: Option<TraceCarrier>,
125 current_stage: LifecycleStage,
126 stage_status: [LifecycleStatus; 5],
127 capabilities: [bool; 4],
128 capability_status: [LifecycleStatus; 4],
129}
130
131impl LifecycleContext {
132 pub fn new(
134 tick_id: TickId,
135 parent_tick_id: Option<TickId>,
136 carrier: Option<TraceCarrier>,
137 ) -> Self {
138 Self {
139 tick_id,
140 parent_tick_id,
141 carrier,
142 span_parent: None,
143 current_stage: LifecycleStage::T1,
144 stage_status: [
145 LifecycleStatus::Started,
146 LifecycleStatus::Skipped,
147 LifecycleStatus::Skipped,
148 LifecycleStatus::Skipped,
149 LifecycleStatus::Skipped,
150 ],
151 capabilities: [false; 4],
152 capability_status: [LifecycleStatus::NotApplicable; 4],
153 }
154 }
155
156 pub fn root(
158 tick_id: TickId,
159 parent_tick_id: Option<TickId>,
160 carrier: Option<TraceCarrier>,
161 ) -> Self {
162 Self::new(tick_id, parent_tick_id, carrier)
163 }
164
165 pub const fn tick_id(&self) -> TickId {
167 self.tick_id
168 }
169
170 pub const fn parent_tick_id(&self) -> Option<TickId> {
172 self.parent_tick_id
173 }
174
175 pub fn carrier(&self) -> Option<&TraceCarrier> {
177 self.carrier.as_ref()
178 }
179
180 pub fn span_parent(&self) -> Option<&TraceCarrier> {
182 self.span_parent.as_ref()
183 }
184
185 pub fn with_span_parent(&self, span_parent: Option<TraceCarrier>) -> Self {
187 let mut next = self.clone();
188 next.span_parent = span_parent;
189 next
190 }
191
192 pub fn otel_parent(&self) -> Option<&TraceCarrier> {
194 self.span_parent.as_ref().or(self.carrier.as_ref())
195 }
196
197 pub const fn current_stage(&self) -> LifecycleStage {
199 self.current_stage
200 }
201
202 pub const fn parent_stage(&self) -> LifecycleStage {
204 LifecycleStage::T1
205 }
206
207 pub const fn stage_status(&self, stage: LifecycleStage) -> LifecycleStatus {
209 self.stage_status[stage.index()]
210 }
211
212 pub fn with_stage_status(&self, stage: LifecycleStage, status: LifecycleStatus) -> Self {
214 let mut next = self.clone();
215 next.stage_status[stage.index()] = status;
216 next.current_stage = stage;
217 next
218 }
219
220 pub const fn has_capability(&self, capability: LifecycleCapability) -> bool {
222 self.capabilities[capability.index()]
223 }
224
225 pub const fn capability_status(&self, capability: LifecycleCapability) -> LifecycleStatus {
227 self.capability_status[capability.index()]
228 }
229
230 pub fn with_capability(&self, capability: LifecycleCapability, enabled: bool) -> Self {
232 let mut next = self.clone();
233 let index = capability.index();
234 next.capabilities[index] = enabled;
235 next.capability_status[index] = if enabled {
236 LifecycleStatus::Skipped
237 } else {
238 LifecycleStatus::NotApplicable
239 };
240 next
241 }
242
243 pub fn with_capability_status(
245 &self,
246 capability: LifecycleCapability,
247 status: LifecycleStatus,
248 ) -> Self {
249 let mut next = self.clone();
250 let index = capability.index();
251 next.capabilities[index] = status != LifecycleStatus::NotApplicable;
252 next.capability_status[index] = status;
253 next
254 }
255
256 pub fn local_stage(&self, stage: LifecycleStage) -> Self {
258 let mut next = self.clone();
259 next.current_stage = stage;
260 next
261 }
262}
263
264impl Default for LifecycleContext {
265 fn default() -> Self {
267 Self::new(0, None, None)
268 }
269}
270
271#[derive(Clone, Debug, PartialEq, Eq)]
273pub struct LifecycleObservation {
274 pub tick_id: TickId,
275 pub parent_tick_id: Option<TickId>,
276 pub stage: LifecycleStage,
277 pub capability: Option<LifecycleCapability>,
278 pub status: LifecycleStatus,
279 pub reason: Option<&'static str>,
281}
282
283pub const LIFECYCLE_TRACE_QUEUE_CAPACITY: usize = 256;
285
286#[derive(Clone, Debug, Default)]
287pub struct LifecycleTraceStats {
288 dropped: Arc<AtomicU64>,
289}
290
291impl LifecycleTraceStats {
292 pub fn dropped_count(&self) -> u64 {
294 self.dropped.load(Ordering::Relaxed)
295 }
296}
297
298#[derive(Clone, Debug)]
300pub struct LifecycleTraceSink {
301 tx: mpsc::Sender<LifecycleObservation>,
302 stats: LifecycleTraceStats,
303}
304
305impl LifecycleTraceSink {
306 pub fn channel() -> (Self, mpsc::Receiver<LifecycleObservation>) {
308 let (tx, rx) = mpsc::channel(LIFECYCLE_TRACE_QUEUE_CAPACITY);
309 (
310 Self {
311 tx,
312 stats: LifecycleTraceStats::default(),
313 },
314 rx,
315 )
316 }
317
318 pub fn try_emit(&self, observation: LifecycleObservation) -> bool {
320 match self.tx.try_send(observation) {
321 Ok(()) => true,
322 Err(_) => {
323 self.stats.dropped.fetch_add(1, Ordering::Relaxed);
324 false
325 }
326 }
327 }
328
329 pub fn stats(&self) -> LifecycleTraceStats {
331 self.stats.clone()
332 }
333}
334
335pub const LIFECYCLE_LINK_CAPACITY: usize = 4096;
337
338#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
339enum LinkKey {
340 Correlation(u64),
341 Timer(u64),
342}
343
344#[derive(Clone, Debug)]
345struct LifecycleLink {
346 generation: u64,
347 parent_tick_id: TickId,
348 carrier: Option<TraceCarrier>,
349 span_parent: Option<TraceCarrier>,
350}
351
352#[derive(Default)]
353struct TrackerState {
354 links: HashMap<LinkKey, LifecycleLink>,
355 order: VecDeque<(LinkKey, u64)>,
356 next_generation: u64,
357}
358
359#[derive(Clone, Default)]
364pub struct LifecycleTracker {
365 next_tick_id: Arc<AtomicU64>,
366 state: Arc<Mutex<TrackerState>>,
367}
368
369impl LifecycleTracker {
370 pub fn next_tick_id(&self) -> TickId {
372 self.next_tick_id
373 .fetch_add(1, Ordering::Relaxed)
374 .saturating_add(1)
375 }
376
377 pub fn parent_for_tick(
379 &self,
380 tick: &Tick,
381 ) -> (Option<TickId>, Option<TraceCarrier>, Option<TraceCarrier>) {
382 let (key, terminal) = match tick {
383 Tick::PortReply { corr, .. } => (Some(LinkKey::Correlation(corr.raw())), true),
384 Tick::PortProgress { corr, .. } => (Some(LinkKey::Correlation(corr.raw())), false),
385 Tick::Timer(id) => (Some(LinkKey::Timer(id.raw())), true),
386 _ => (None, false),
387 };
388 let Some(key) = key else {
389 return (None, None, None);
390 };
391 let mut state = self.state.lock();
392 let link = if terminal {
393 state.links.remove(&key)
394 } else {
395 state.links.get(&key).cloned()
396 };
397 link.map_or((None, None, None), |link| {
398 (Some(link.parent_tick_id), link.carrier, link.span_parent)
399 })
400 }
401
402 pub fn remember_effect(&self, effect: &OwnedEffect, context: &LifecycleContext) {
404 let (key, carrier, span_parent) = match effect {
405 OwnedEffect::Persist { corr, .. }
406 | OwnedEffect::PersistAtomic { corr, .. }
407 | OwnedEffect::Http { corr, .. }
408 | OwnedEffect::UploadFile { corr, .. }
409 | OwnedEffect::Request { corr, .. } => (
410 Some(LinkKey::Correlation(corr.raw())),
411 context.carrier.clone(),
412 context.span_parent.clone(),
413 ),
414 OwnedEffect::ScheduleTimer { id, .. } => (
415 Some(LinkKey::Timer(id.raw())),
416 context.carrier.clone(),
417 context.span_parent.clone(),
418 ),
419 OwnedEffect::CancelTimer { id } => {
420 self.forget(LinkKey::Timer(id.raw()));
421 (None, None, None)
422 }
423 _ => (None, None, None),
424 };
425 let Some(key) = key else {
426 return;
427 };
428 let mut state = self.state.lock();
429 let generation = state
430 .links
431 .get(&key)
432 .map(|link| link.generation)
433 .unwrap_or_else(|| {
434 state.next_generation = state.next_generation.wrapping_add(1);
435 let generation = state.next_generation;
436 state.order.push_back((key, generation));
437 generation
438 });
439 state.links.insert(
440 key,
441 LifecycleLink {
442 generation,
443 parent_tick_id: context.tick_id,
444 carrier,
445 span_parent,
446 },
447 );
448 compact_tracker_order_if_needed(&mut state);
449 while state.links.len() > LIFECYCLE_LINK_CAPACITY {
450 let Some((oldest, generation)) = state.order.pop_front() else {
451 break;
452 };
453 if state
454 .links
455 .get(&oldest)
456 .is_some_and(|link| link.generation == generation)
457 {
458 state.links.remove(&oldest);
459 }
460 }
461 }
462
463 fn forget(&self, key: LinkKey) {
465 let mut state = self.state.lock();
466 state.links.remove(&key);
467 }
468}
469
470fn compact_tracker_order_if_needed(state: &mut TrackerState) {
472 const COMPACTION_FACTOR: usize = 4;
473 if state.order.len() <= LIFECYCLE_LINK_CAPACITY * COMPACTION_FACTOR {
474 return;
475 }
476 state.order.retain(|(key, generation)| {
477 state
478 .links
479 .get(key)
480 .is_some_and(|link| link.generation == *generation)
481 });
482}
483
484pub type LifecycleCorrelation = Correlation;
486pub type LifecycleTimer = TimerId;
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use bytes::Bytes;
493 use helix_core::effect::HttpRequest;
494
495 #[test]
496 fn context_tree_has_independent_ticks_and_t1_rooted_local_stages() {
497 let root = LifecycleContext::new(7, None, None)
498 .with_capability(LifecycleCapability::Ws, false)
499 .with_capability(LifecycleCapability::Http, true);
500 let local = root
501 .local_stage(LifecycleStage::T4)
502 .with_stage_status(LifecycleStage::T4, LifecycleStatus::Started);
503 let next = LifecycleContext::new(8, Some(7), None);
504
505 assert_eq!(root.tick_id(), 7);
506 assert_eq!(next.tick_id(), 8);
507 assert_eq!(next.parent_tick_id(), Some(7));
508 assert_eq!(local.parent_stage(), LifecycleStage::T1);
509 assert_eq!(
510 local.stage_status(LifecycleStage::T4),
511 LifecycleStatus::Started
512 );
513 assert_eq!(
514 root.capability_status(LifecycleCapability::Ws),
515 LifecycleStatus::NotApplicable
516 );
517 assert_eq!(
518 root.capability_status(LifecycleCapability::Http),
519 LifecycleStatus::Skipped
520 );
521 }
522
523 #[test]
524 fn tracker_keeps_parent_and_carrier_isolated_across_correlations() {
525 let tracker = LifecycleTracker::default();
526 let carrier = TraceCarrier::from_headers(&[(
527 "traceparent".to_string(),
528 "00-00000000000000000000000000000001-0000000000000002-01".to_string(),
529 )]);
530 let first = LifecycleContext::new(11, None, carrier);
531 let second = LifecycleContext::new(12, None, None);
532 let first_effect = OwnedEffect::Http {
533 corr: Correlation::from_raw(1),
534 req: HttpRequest {
535 method: "GET".to_string(),
536 url: "https://example.test".to_string(),
537 headers: Vec::new(),
538 body: None,
539 },
540 };
541 let second_effect = OwnedEffect::Http {
542 corr: Correlation::from_raw(2),
543 req: HttpRequest {
544 method: "GET".to_string(),
545 url: "https://example.test".to_string(),
546 headers: Vec::new(),
547 body: None,
548 },
549 };
550 tracker.remember_effect(&first_effect, &first);
551 tracker.remember_effect(&second_effect, &second);
552 let (first_parent, first_carrier, first_span_parent) =
553 tracker.parent_for_tick(&Tick::PortReply {
554 corr: Correlation::from_raw(1),
555 outcome: helix_core::tick::PortOutcome::Ok(helix_core::tick::ReplyBytes(
556 Bytes::new(),
557 )),
558 });
559 let (second_parent, second_carrier, second_span_parent) =
560 tracker.parent_for_tick(&Tick::PortReply {
561 corr: Correlation::from_raw(2),
562 outcome: helix_core::tick::PortOutcome::Ok(helix_core::tick::ReplyBytes(
563 Bytes::new(),
564 )),
565 });
566 assert_eq!(first_parent, Some(11));
567 assert_eq!(second_parent, Some(12));
568 assert!(first_carrier.is_some());
569 assert!(second_carrier.is_none());
570 assert!(first_span_parent.is_none());
571 assert!(second_span_parent.is_none());
572 }
573
574 #[test]
575 fn lifecycle_sink_is_non_blocking_and_bounded() {
576 let (sink, mut rx) = LifecycleTraceSink::channel();
577 let context = LifecycleContext::new(1, None, None);
578 for _ in 0..LIFECYCLE_TRACE_QUEUE_CAPACITY {
579 assert!(sink.try_emit(LifecycleObservation {
580 tick_id: context.tick_id(),
581 parent_tick_id: context.parent_tick_id(),
582 stage: LifecycleStage::T1,
583 capability: None,
584 status: LifecycleStatus::Started,
585 reason: None,
586 }));
587 }
588 assert!(!sink.try_emit(LifecycleObservation {
589 tick_id: 1,
590 parent_tick_id: None,
591 stage: LifecycleStage::T4,
592 capability: Some(LifecycleCapability::Http),
593 status: LifecycleStatus::NotApplicable,
594 reason: Some("transport_absent"),
595 }));
596 assert_eq!(sink.stats().dropped_count(), 1);
597 rx.close();
598 }
599
600 #[test]
601 fn capability_matrix_marks_http_only_ws_only_and_no_network_explicitly() {
602 let no_network = LifecycleContext::new(1, None, None);
603 let http_only = no_network.with_capability(LifecycleCapability::Http, true);
604 let ws_only = no_network.with_capability(LifecycleCapability::Ws, true);
605
606 assert_eq!(
607 no_network.capability_status(LifecycleCapability::Http),
608 LifecycleStatus::NotApplicable
609 );
610 assert_eq!(
611 no_network.capability_status(LifecycleCapability::Ws),
612 LifecycleStatus::NotApplicable
613 );
614 assert_eq!(
615 http_only.capability_status(LifecycleCapability::Http),
616 LifecycleStatus::Skipped
617 );
618 assert_eq!(
619 http_only.capability_status(LifecycleCapability::Ws),
620 LifecycleStatus::NotApplicable
621 );
622 assert_eq!(
623 ws_only.capability_status(LifecycleCapability::Ws),
624 LifecycleStatus::Skipped
625 );
626 assert_eq!(
627 ws_only.capability_status(LifecycleCapability::Http),
628 LifecycleStatus::NotApplicable
629 );
630 }
631
632 #[test]
633 fn concurrent_trackers_keep_parent_links_isolated() {
634 let tracker = LifecycleTracker::default();
635 let first_tracker = tracker.clone();
636 let second_tracker = tracker.clone();
637 let first = std::thread::spawn(move || {
638 let context = LifecycleContext::new(101, None, None);
639 let effect = OwnedEffect::Http {
640 corr: Correlation::from_raw(101),
641 req: HttpRequest {
642 method: "GET".to_string(),
643 url: "https://example.test/one".to_string(),
644 headers: Vec::new(),
645 body: None,
646 },
647 };
648 first_tracker.remember_effect(&effect, &context);
649 });
650 let second = std::thread::spawn(move || {
651 let context = LifecycleContext::new(202, None, None);
652 let effect = OwnedEffect::Http {
653 corr: Correlation::from_raw(202),
654 req: HttpRequest {
655 method: "GET".to_string(),
656 url: "https://example.test/two".to_string(),
657 headers: Vec::new(),
658 body: None,
659 },
660 };
661 second_tracker.remember_effect(&effect, &context);
662 });
663 assert!(first.join().is_ok());
664 assert!(second.join().is_ok());
665
666 let (first_parent, _, _) = tracker.parent_for_tick(&Tick::PortReply {
667 corr: Correlation::from_raw(101),
668 outcome: helix_core::tick::PortOutcome::Err(helix_core::tick::PortError::Timeout),
669 });
670 let (second_parent, _, _) = tracker.parent_for_tick(&Tick::PortReply {
671 corr: Correlation::from_raw(202),
672 outcome: helix_core::tick::PortOutcome::Err(helix_core::tick::PortError::Timeout),
673 });
674 assert_eq!(first_parent, Some(101));
675 assert_eq!(second_parent, Some(202));
676 }
677}