1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use std::fs;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
15use std::sync::{Arc, Mutex, Weak};
16use std::time::Duration;
17
18use serde::{Deserialize, Serialize};
19use tailtriage_core::{
20 BuildError, CaptureLimitsOverride, CaptureMode, InflightGuard, Outcome, OwnedRequestCompletion,
21 OwnedRequestHandle, QueueTimer, RequestOptions, RunEndReason, StageTimer, Tailtriage,
22};
23use tailtriage_tokio::{RuntimeSampler, SamplerStartError};
24
25#[derive(Debug, Clone)]
27pub struct TailtriageControllerBuilder {
28 service_name: String,
29 config_path: Option<PathBuf>,
30 initially_enabled: bool,
31 sink_template: ControllerSinkTemplate,
32 capture_limits_override: CaptureLimitsOverride,
33 strict_lifecycle: bool,
34 runtime_sampler: RuntimeSamplerTemplate,
35 run_end_policy: RunEndPolicy,
36}
37
38impl TailtriageControllerBuilder {
39 #[must_use]
41 pub fn new(service_name: impl Into<String>) -> Self {
42 Self {
43 service_name: service_name.into(),
44 config_path: None,
45 initially_enabled: false,
46 sink_template: ControllerSinkTemplate::LocalJson {
47 output_path: PathBuf::from("tailtriage-run.json"),
48 },
49 capture_limits_override: CaptureLimitsOverride::default(),
50 strict_lifecycle: false,
51 runtime_sampler: RuntimeSamplerTemplate::default(),
52 run_end_policy: RunEndPolicy::ContinueAfterLimitsHit,
53 }
54 }
55
56 #[must_use]
58 pub fn config_path(mut self, config_path: impl AsRef<Path>) -> Self {
59 self.config_path = Some(config_path.as_ref().to_path_buf());
60 self
61 }
62
63 #[must_use]
68 pub const fn initially_enabled(mut self, initially_enabled: bool) -> Self {
69 self.initially_enabled = initially_enabled;
70 self
71 }
72
73 #[must_use]
75 pub fn output(mut self, output_path: impl AsRef<Path>) -> Self {
76 self.sink_template = ControllerSinkTemplate::LocalJson {
77 output_path: output_path.as_ref().to_path_buf(),
78 };
79 self
80 }
81
82 #[must_use]
84 pub const fn capture_limits_override(
85 mut self,
86 capture_limits_override: CaptureLimitsOverride,
87 ) -> Self {
88 self.capture_limits_override = capture_limits_override;
89 self
90 }
91
92 #[must_use]
94 pub const fn strict_lifecycle(mut self, strict_lifecycle: bool) -> Self {
95 self.strict_lifecycle = strict_lifecycle;
96 self
97 }
98
99 #[must_use]
101 pub const fn runtime_sampler(mut self, runtime_sampler: RuntimeSamplerTemplate) -> Self {
102 self.runtime_sampler = runtime_sampler;
103 self
104 }
105
106 #[must_use]
108 pub const fn run_end_policy(mut self, run_end_policy: RunEndPolicy) -> Self {
109 self.run_end_policy = run_end_policy;
110 self
111 }
112
113 pub fn build(self) -> Result<TailtriageController, ControllerBuildError> {
130 let mut service_name = self.service_name;
131 let mut initially_enabled = self.initially_enabled;
132 let mut sink_template = self.sink_template;
133 let mut selected_mode = CaptureMode::Light;
134 let mut capture_limits_override = self.capture_limits_override;
135 let mut strict_lifecycle = self.strict_lifecycle;
136 let mut runtime_sampler = self.runtime_sampler;
137 let mut run_end_policy = self.run_end_policy;
138
139 if let Some(config_path) = self.config_path.as_ref() {
140 let loaded = TailtriageController::load_config_from_path(config_path)
141 .map_err(ControllerBuildError::ConfigLoad)?;
142 let activation = loaded.activation_template;
143 service_name = loaded.service_name.unwrap_or(service_name);
144 initially_enabled = loaded.initially_enabled.unwrap_or(initially_enabled);
145 sink_template = activation.sink_template;
146 selected_mode = activation.selected_mode;
147 capture_limits_override = activation.capture_limits_override;
148 strict_lifecycle = activation.strict_lifecycle;
149 runtime_sampler = activation.runtime_sampler;
150 run_end_policy = activation.run_end_policy;
151 }
152
153 if service_name.trim().is_empty() {
154 return Err(ControllerBuildError::EmptyServiceName);
155 }
156
157 let template = TailtriageControllerTemplate {
158 service_name,
159 config_path: self.config_path,
160 sink_template,
161 selected_mode: CaptureMode::Light,
162 capture_limits_override,
163 strict_lifecycle,
164 runtime_sampler,
165 run_end_policy,
166 };
167 let template = TailtriageControllerTemplate {
168 selected_mode,
169 ..template
170 };
171
172 let inner = Arc::new(ControllerInner {
173 template: Mutex::new(template),
174 lifecycle: Mutex::new(ControllerLifecycle::Disabled { next_generation: 1 }),
175 inert_request_seq: AtomicU64::new(1),
176 });
177
178 let controller = TailtriageController { inner };
179 if initially_enabled {
180 controller
181 .enable()
182 .map_err(ControllerBuildError::InitialEnable)?;
183 }
184
185 Ok(controller)
186 }
187}
188
189#[derive(Debug, Clone)]
191pub struct TailtriageController {
192 inner: Arc<ControllerInner>,
193}
194
195#[derive(Debug)]
196struct ControllerInner {
197 template: Mutex<TailtriageControllerTemplate>,
198 lifecycle: Mutex<ControllerLifecycle>,
199 inert_request_seq: AtomicU64,
200}
201
202#[derive(Debug)]
203struct ActiveGenerationRuntime {
204 state: ActiveGenerationState,
205 artifact_path: PathBuf,
206 run: Arc<Tailtriage>,
207 accepting_new: AtomicBool,
208 closing: AtomicBool,
209 inflight_captured: AtomicU64,
210 finalize_started: AtomicBool,
211 last_finalize_error: Mutex<Option<String>>,
212 runtime_sampler: Mutex<Option<RuntimeSampler>>,
213}
214
215impl ActiveGenerationRuntime {
216 fn snapshot(&self) -> ActiveGenerationState {
217 ActiveGenerationState {
218 generation_id: self.state.generation_id,
219 started_at_unix_ms: self.state.started_at_unix_ms,
220 artifact_path: self.artifact_path.clone(),
221 accepting_new_admissions: self.accepting_new.load(Ordering::Relaxed),
222 closing: self.closing.load(Ordering::Relaxed),
223 inflight_captured_requests: self.inflight_captured.load(Ordering::Relaxed),
224 finalization_in_progress: self.finalize_started.load(Ordering::Relaxed),
225 last_finalize_error: self
226 .last_finalize_error
227 .lock()
228 .unwrap_or_else(std::sync::PoisonError::into_inner)
229 .clone(),
230 activation_config: self.state.activation_config.clone(),
231 }
232 }
233
234 fn clear_finalize_error(&self) {
235 let mut last_error = self
236 .last_finalize_error
237 .lock()
238 .unwrap_or_else(std::sync::PoisonError::into_inner);
239 *last_error = None;
240 }
241
242 fn record_finalize_error(&self, error: &DisableError) {
243 let mut last_error = self
244 .last_finalize_error
245 .lock()
246 .unwrap_or_else(std::sync::PoisonError::into_inner);
247 *last_error = Some(error.to_string());
248 }
249}
250
251impl TailtriageController {
252 fn validate_template(template: &TailtriageControllerTemplate) -> Result<(), BuildError> {
253 let artifact_path = generated_artifact_path(&template.sink_template, 1);
254 let run_id = format!("{}-generation-1", template.service_name);
255
256 let mut builder = Tailtriage::builder(template.service_name.clone())
257 .run_id(run_id)
258 .output(&artifact_path);
259 builder = match template.selected_mode {
260 CaptureMode::Light => builder.light(),
261 CaptureMode::Investigation => builder.investigation(),
262 };
263 builder = builder.capture_limits_override(template.capture_limits_override);
264 builder = builder.strict_lifecycle(template.strict_lifecycle);
265 let _ = builder.build()?;
266 Ok(())
267 }
268
269 fn next_inert_request_id(&self) -> String {
270 let id = self.inner.inert_request_seq.fetch_add(1, Ordering::Relaxed);
271 format!("inert-{id}")
272 }
273
274 #[must_use]
276 pub fn builder(service_name: impl Into<String>) -> TailtriageControllerBuilder {
277 TailtriageControllerBuilder::new(service_name)
278 }
279
280 pub fn load_config_from_path(
289 path: impl AsRef<Path>,
290 ) -> Result<LoadedControllerConfig, ConfigLoadError> {
291 let path = path.as_ref();
292 let file = ControllerConfigFile::from_path(path)?;
293 Ok(file.into_loaded())
294 }
295
296 #[must_use]
299 pub fn status(&self) -> TailtriageControllerStatus {
300 let template = self
301 .inner
302 .template
303 .lock()
304 .unwrap_or_else(std::sync::PoisonError::into_inner);
305 let lifecycle = self
306 .inner
307 .lifecycle
308 .lock()
309 .unwrap_or_else(std::sync::PoisonError::into_inner);
310
311 TailtriageControllerStatus {
312 template: template.clone(),
313 generation: lifecycle.snapshot(),
314 }
315 }
316
317 pub fn reload_template(&self, next_template: TailtriageControllerTemplate) {
326 self.try_reload_template(next_template)
327 .expect("invalid template for reload_template");
328 }
329
330 pub fn try_reload_template(
342 &self,
343 next_template: TailtriageControllerTemplate,
344 ) -> Result<(), ReloadTemplateError> {
345 Self::validate_template(&next_template).map_err(ReloadTemplateError::Validate)?;
346 let mut template = self
347 .inner
348 .template
349 .lock()
350 .unwrap_or_else(std::sync::PoisonError::into_inner);
351 *template = next_template;
352 Ok(())
353 }
354
355 pub fn reload_config(&self) -> Result<(), ReloadConfigError> {
366 let (config_path, service_name) = {
367 let template = self
368 .inner
369 .template
370 .lock()
371 .unwrap_or_else(std::sync::PoisonError::into_inner);
372 let Some(config_path) = template.config_path.clone() else {
373 return Err(ReloadConfigError::MissingConfigPath);
374 };
375 (config_path, template.service_name.clone())
376 };
377
378 let loaded = TailtriageController::load_config_from_path(&config_path)
379 .map_err(ReloadConfigError::Load)?;
380 let activation = loaded.activation_template;
381 let validated = TailtriageControllerTemplate {
382 service_name: loaded.service_name.unwrap_or(service_name),
383 config_path: Some(config_path),
384 sink_template: activation.sink_template,
385 selected_mode: activation.selected_mode,
386 capture_limits_override: activation.capture_limits_override,
387 strict_lifecycle: activation.strict_lifecycle,
388 runtime_sampler: activation.runtime_sampler,
389 run_end_policy: activation.run_end_policy,
390 };
391
392 Self::validate_template(&validated).map_err(ReloadConfigError::Validate)?;
393
394 let mut template = self
395 .inner
396 .template
397 .lock()
398 .unwrap_or_else(std::sync::PoisonError::into_inner);
399 *template = validated;
400
401 Ok(())
402 }
403
404 pub fn enable(&self) -> Result<ActiveGenerationState, EnableError> {
420 let template = self
421 .inner
422 .template
423 .lock()
424 .unwrap_or_else(std::sync::PoisonError::into_inner)
425 .clone();
426
427 let mut lifecycle = self
428 .inner
429 .lifecycle
430 .lock()
431 .unwrap_or_else(std::sync::PoisonError::into_inner);
432
433 let next_generation = match *lifecycle {
434 ControllerLifecycle::Disabled { next_generation } => next_generation,
435 ControllerLifecycle::Active { ref active, .. } => {
436 return Err(EnableError::AlreadyActive {
437 generation_id: active.state.generation_id,
438 });
439 }
440 };
441
442 let artifact_path = generated_artifact_path(&template.sink_template, next_generation);
443 let run_id = format!("{}-generation-{next_generation}", template.service_name);
444
445 let mut builder = Tailtriage::builder(template.service_name.clone())
446 .run_id(run_id)
447 .output(&artifact_path);
448
449 builder = match template.selected_mode {
450 CaptureMode::Light => builder.light(),
451 CaptureMode::Investigation => builder.investigation(),
452 };
453 builder = builder.capture_limits_override(template.capture_limits_override);
454 builder = builder.strict_lifecycle(template.strict_lifecycle);
455
456 let run = Arc::new(builder.build().map_err(EnableError::Build)?);
457 let generation_started_at_unix_ms = run.snapshot().metadata.started_at_unix_ms;
458 let runtime = Arc::new(ActiveGenerationRuntime {
459 state: ActiveGenerationState {
460 generation_id: next_generation,
461 started_at_unix_ms: generation_started_at_unix_ms,
462 artifact_path: artifact_path.clone(),
463 accepting_new_admissions: true,
464 closing: false,
465 inflight_captured_requests: 0,
466 finalization_in_progress: false,
467 last_finalize_error: None,
468 activation_config: ControllerActivationTemplate {
469 sink_template: template.sink_template.clone(),
470 selected_mode: template.selected_mode,
471 capture_limits_override: template.capture_limits_override,
472 strict_lifecycle: template.strict_lifecycle,
473 runtime_sampler: template.runtime_sampler,
474 run_end_policy: template.run_end_policy,
475 },
476 },
477 artifact_path,
478 run: Arc::clone(&run),
479 accepting_new: AtomicBool::new(true),
480 closing: AtomicBool::new(false),
481 inflight_captured: AtomicU64::new(0),
482 finalize_started: AtomicBool::new(false),
483 last_finalize_error: Mutex::new(None),
484 runtime_sampler: Mutex::new(None),
485 });
486 if template.run_end_policy == RunEndPolicy::AutoSealOnLimitsHit {
487 let active = Arc::downgrade(&runtime);
488 let inner = Arc::downgrade(&self.inner);
489 let listener: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
490 TailtriageController::on_limits_hit_signal(&inner, &active);
491 });
492 runtime.run.set_limits_hit_listener(Some(listener));
493 }
494
495 if template.runtime_sampler.enabled_for_armed_runs {
496 let _ = tokio::runtime::Handle::try_current()
497 .map_err(|_| EnableError::MissingTokioRuntimeForSampler)?;
498 let mut sampler_builder = RuntimeSampler::builder(Arc::clone(&run));
499 if let Some(mode_override) = template.runtime_sampler.mode_override {
500 sampler_builder = sampler_builder.mode(mode_override);
501 }
502 if let Some(interval_ms) = template.runtime_sampler.interval_ms {
503 sampler_builder = sampler_builder.interval(Duration::from_millis(interval_ms));
504 }
505 if let Some(max_runtime_snapshots) = template.runtime_sampler.max_runtime_snapshots {
506 sampler_builder = sampler_builder.max_runtime_snapshots(max_runtime_snapshots);
507 }
508 let runtime_sampler = sampler_builder
509 .start()
510 .map_err(EnableError::StartRuntimeSampler)?;
511 let mut sampler_slot = runtime
512 .runtime_sampler
513 .lock()
514 .unwrap_or_else(std::sync::PoisonError::into_inner);
515 *sampler_slot = Some(runtime_sampler);
516 }
517
518 *lifecycle = ControllerLifecycle::Active {
519 active: Arc::clone(&runtime),
520 next_generation: next_generation.saturating_add(1),
521 };
522
523 Ok(runtime.snapshot())
524 }
525
526 pub fn disable(&self) -> Result<DisableOutcome, DisableError> {
537 let (active, next_generation, generation_id) = {
538 let lifecycle = self
539 .inner
540 .lifecycle
541 .lock()
542 .unwrap_or_else(std::sync::PoisonError::into_inner);
543
544 let ControllerLifecycle::Active {
545 ref active,
546 next_generation,
547 } = *lifecycle
548 else {
549 return Ok(DisableOutcome::AlreadyDisabled);
550 };
551
552 active
553 .run
554 .set_run_end_reason_if_absent(RunEndReason::ManualDisarm);
555 active.accepting_new.store(false, Ordering::Relaxed);
556 active.closing.store(true, Ordering::Relaxed);
557
558 if active.inflight_captured.load(Ordering::Relaxed) == 0 {
559 (
560 Some(Arc::clone(active)),
561 Some(next_generation),
562 active.state.generation_id,
563 )
564 } else {
565 return Ok(DisableOutcome::Closing {
566 generation_id: active.state.generation_id,
567 inflight_captured_requests: active.inflight_captured.load(Ordering::Relaxed),
568 });
569 }
570 };
571
572 if let (Some(active), Some(next_generation)) = (active, next_generation) {
573 Self::finalize_active(&self.inner, &active, next_generation)?;
574 }
575
576 Ok(DisableOutcome::Finalized { generation_id })
577 }
578
579 pub fn begin_request_with(
592 &self,
593 route: impl Into<String>,
594 options: RequestOptions,
595 ) -> ControllerStartedRequest {
596 let route = route.into();
597 if let Some(started) = self.try_begin_request_with(route.clone(), options.clone()) {
598 return started;
599 }
600
601 ControllerStartedRequest {
602 handle: ControllerRequestHandle::Inert(InertControllerRequestHandle::new(
603 route,
604 options,
605 self.next_inert_request_id(),
606 )),
607 completion: ControllerRequestCompletion {
608 kind: ControllerCompletionKind::Inert,
609 },
610 }
611 }
612
613 pub fn begin_request(&self, route: impl Into<String>) -> ControllerStartedRequest {
615 self.begin_request_with(route, RequestOptions::new())
616 }
617
618 #[must_use]
629 pub fn try_begin_request_with(
630 &self,
631 route: impl Into<String>,
632 options: RequestOptions,
633 ) -> Option<ControllerStartedRequest> {
634 let active = {
635 let lifecycle = self
636 .inner
637 .lifecycle
638 .lock()
639 .unwrap_or_else(std::sync::PoisonError::into_inner);
640
641 match *lifecycle {
642 ControllerLifecycle::Active { ref active, .. } => Arc::clone(active),
643 ControllerLifecycle::Disabled { .. } => return None,
644 }
645 };
646
647 if !active.accepting_new.load(Ordering::Acquire) {
648 return None;
649 }
650
651 if active.state.activation_config.run_end_policy == RunEndPolicy::AutoSealOnLimitsHit
652 && active.run.snapshot().truncation.limits_hit
653 {
654 active
655 .run
656 .set_run_end_reason_if_absent(RunEndReason::AutoSealOnLimitsHit);
657 active.accepting_new.store(false, Ordering::Release);
658 active.closing.store(true, Ordering::Release);
659 if active.inflight_captured.load(Ordering::Acquire) == 0 {
660 let _ = self.force_finalize_generation(&active);
661 }
662 return None;
663 }
664
665 active.inflight_captured.fetch_add(1, Ordering::AcqRel);
666 if !active.accepting_new.load(Ordering::Acquire) {
667 active.inflight_captured.fetch_sub(1, Ordering::AcqRel);
668 return None;
669 }
670
671 let started = active.run.begin_request_with_owned(route, options);
675 Self::apply_run_end_policy_if_limits_hit(&active);
676
677 Some(ControllerStartedRequest {
678 handle: ControllerRequestHandle::Active(started.handle),
679 completion: ControllerRequestCompletion {
680 kind: ControllerCompletionKind::Active(ActiveControllerCompletion {
681 completion: Some(started.completion),
682 admission_generation_id: active.state.generation_id,
683 admitted_generation: Arc::downgrade(&active),
684 inner: Arc::downgrade(&self.inner),
685 run_end_policy: active.state.activation_config.run_end_policy,
686 inflight_recorded: true,
687 }),
688 },
689 })
690 }
691
692 #[must_use]
696 pub fn try_begin_request(&self, route: impl Into<String>) -> Option<ControllerStartedRequest> {
697 self.try_begin_request_with(route, RequestOptions::new())
698 }
699
700 pub fn shutdown(&self) -> Result<(), ShutdownError> {
711 let maybe_active = {
712 let lifecycle = self
713 .inner
714 .lifecycle
715 .lock()
716 .unwrap_or_else(std::sync::PoisonError::into_inner);
717 match *lifecycle {
718 ControllerLifecycle::Active { ref active, .. } => Some(Arc::clone(active)),
719 ControllerLifecycle::Disabled { .. } => None,
720 }
721 };
722
723 if let Some(active) = maybe_active {
724 active
725 .run
726 .set_run_end_reason_if_absent(RunEndReason::Shutdown);
727 active.accepting_new.store(false, Ordering::Relaxed);
728 active.closing.store(true, Ordering::Relaxed);
729 self.force_finalize_generation(&active)
730 .map_err(ShutdownError::Finalize)?;
731 }
732
733 Ok(())
734 }
735
736 fn force_finalize_generation(
737 &self,
738 active: &Arc<ActiveGenerationRuntime>,
739 ) -> Result<(), DisableError> {
740 Self::finalize_generation_shared(&self.inner, active)
741 }
742
743 fn finalize_generation_shared(
744 inner: &Arc<ControllerInner>,
745 active: &Arc<ActiveGenerationRuntime>,
746 ) -> Result<(), DisableError> {
747 let next_generation = {
748 let lifecycle = inner
749 .lifecycle
750 .lock()
751 .unwrap_or_else(std::sync::PoisonError::into_inner);
752 match *lifecycle {
753 ControllerLifecycle::Active {
754 active: ref current_active,
755 next_generation,
756 } if current_active.state.generation_id == active.state.generation_id => {
757 next_generation
758 }
759 _ => return Ok(()),
760 }
761 };
762
763 Self::finalize_active(inner, active, next_generation)
764 }
765
766 fn finalize_active(
767 inner: &Arc<ControllerInner>,
768 active: &Arc<ActiveGenerationRuntime>,
769 next_generation: u64,
770 ) -> Result<(), DisableError> {
771 if active
772 .finalize_started
773 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
774 .is_err()
775 {
776 return Ok(());
777 }
778
779 active.clear_finalize_error();
780 Self::stop_runtime_sampler(active);
781 if let Err(source) = active.run.shutdown() {
782 let error = DisableError::Finalize(source);
783 active.record_finalize_error(&error);
784 active.finalize_started.store(false, Ordering::Release);
785 return Err(error);
786 }
787
788 let mut lifecycle = inner
789 .lifecycle
790 .lock()
791 .unwrap_or_else(std::sync::PoisonError::into_inner);
792
793 if matches!(
794 *lifecycle,
795 ControllerLifecycle::Active {
796 active: ref current_active,
797 next_generation: ng,
798 } if current_active.state.generation_id == active.state.generation_id && ng == next_generation
799 ) {
800 *lifecycle = ControllerLifecycle::Disabled { next_generation };
801 }
802
803 Ok(())
804 }
805
806 fn stop_runtime_sampler(active: &Arc<ActiveGenerationRuntime>) {
807 let sampler = active
808 .runtime_sampler
809 .lock()
810 .unwrap_or_else(std::sync::PoisonError::into_inner)
811 .take();
812 if let Some(sampler) = sampler {
813 let shutdown_thread = std::thread::spawn(move || {
814 let runtime = tokio::runtime::Builder::new_current_thread()
815 .enable_all()
816 .build()
817 .expect("sampler shutdown runtime should build");
818 runtime.block_on(sampler.shutdown());
819 });
820 let _ = shutdown_thread.join();
821 }
822 }
823
824 fn apply_run_end_policy_if_limits_hit(active: &Arc<ActiveGenerationRuntime>) {
825 if active.state.activation_config.run_end_policy != RunEndPolicy::AutoSealOnLimitsHit {
826 return;
827 }
828
829 if !active.run.snapshot().truncation.limits_hit {
830 return;
831 }
832
833 active
834 .run
835 .set_run_end_reason_if_absent(RunEndReason::AutoSealOnLimitsHit);
836 active.accepting_new.store(false, Ordering::Release);
837 active.closing.store(true, Ordering::Release);
838 }
839
840 fn on_limits_hit_signal(inner: &Weak<ControllerInner>, active: &Weak<ActiveGenerationRuntime>) {
841 let Some(active) = active.upgrade() else {
842 return;
843 };
844 active
845 .run
846 .set_run_end_reason_if_absent(RunEndReason::AutoSealOnLimitsHit);
847 active.accepting_new.store(false, Ordering::Release);
848 active.closing.store(true, Ordering::Release);
849
850 if active.inflight_captured.load(Ordering::Acquire) > 0 {
851 return;
852 }
853
854 let Some(inner) = inner.upgrade() else {
855 return;
856 };
857 let _ = TailtriageController::finalize_generation_shared(&inner, &active);
858 }
859}
860
861#[must_use = "request completion must be finished explicitly"]
863#[derive(Debug)]
864pub struct ControllerStartedRequest {
865 pub handle: ControllerRequestHandle,
867 pub completion: ControllerRequestCompletion,
869}
870
871#[must_use = "request completion must be finished explicitly"]
873#[derive(Debug)]
874pub struct ControllerRequestCompletion {
875 kind: ControllerCompletionKind,
876}
877
878impl ControllerRequestCompletion {
879 pub fn finish(mut self, outcome: Outcome) {
881 if let ControllerCompletionKind::Active(active) = &mut self.kind {
882 if let Some(completion) = active.completion.take() {
883 completion.finish(outcome);
884 active.mark_finished();
885 }
886 }
887 }
888
889 pub fn finish_ok(self) {
891 self.finish(Outcome::Ok);
892 }
893
894 pub fn finish_result<T, E>(mut self, result: Result<T, E>) -> Result<T, E> {
901 if let ControllerCompletionKind::Active(active) = &mut self.kind {
902 if let Some(completion) = active.completion.take() {
903 completion.finish(if result.is_ok() {
904 Outcome::Ok
905 } else {
906 Outcome::Error
907 });
908 active.mark_finished();
909 }
910 }
911 result
912 }
913}
914
915#[derive(Debug)]
916enum ControllerCompletionKind {
917 Active(ActiveControllerCompletion),
918 Inert,
919}
920
921#[derive(Debug)]
922struct ActiveControllerCompletion {
923 completion: Option<OwnedRequestCompletion>,
924 admission_generation_id: u64,
930 admitted_generation: Weak<ActiveGenerationRuntime>,
936 inner: Weak<ControllerInner>,
937 run_end_policy: RunEndPolicy,
938 inflight_recorded: bool,
939}
940
941impl ActiveControllerCompletion {
942 fn mark_finished(&mut self) {
943 if !self.inflight_recorded {
944 return;
945 }
946
947 self.inflight_recorded = false;
948
949 let Some(active) = self.admitted_generation.upgrade() else {
950 return;
951 };
952
953 debug_assert_eq!(
954 active.state.generation_id, self.admission_generation_id,
955 "controller completion generation binding should remain stable"
956 );
957
958 if self.run_end_policy == RunEndPolicy::AutoSealOnLimitsHit
959 && active.run.snapshot().truncation.limits_hit
960 {
961 active
962 .run
963 .set_run_end_reason_if_absent(RunEndReason::AutoSealOnLimitsHit);
964 active.accepting_new.store(false, Ordering::Release);
965 active.closing.store(true, Ordering::Release);
966 }
967
968 let remaining = active
969 .inflight_captured
970 .fetch_sub(1, Ordering::AcqRel)
971 .saturating_sub(1);
972
973 if remaining == 0 && active.closing.load(Ordering::Acquire) {
974 self.try_finalize_bound_generation(&active);
975 }
976 }
977
978 fn try_finalize_bound_generation(&self, active: &Arc<ActiveGenerationRuntime>) {
979 let Some(inner) = self.inner.upgrade() else {
980 return;
981 };
982 let _ = TailtriageController::finalize_generation_shared(&inner, active);
983 }
984}
985
986#[derive(Debug, Clone)]
988pub enum ControllerRequestHandle {
989 Active(OwnedRequestHandle),
991 Inert(InertControllerRequestHandle),
993}
994
995impl ControllerRequestHandle {
996 #[must_use]
998 pub fn request_id(&self) -> &str {
999 match self {
1000 Self::Active(handle) => handle.request_id(),
1001 Self::Inert(handle) => handle.request_id(),
1002 }
1003 }
1004
1005 #[must_use]
1007 pub fn route(&self) -> &str {
1008 match self {
1009 Self::Active(handle) => handle.route(),
1010 Self::Inert(handle) => handle.route(),
1011 }
1012 }
1013
1014 #[must_use]
1016 pub fn kind(&self) -> Option<&str> {
1017 match self {
1018 Self::Active(handle) => handle.kind(),
1019 Self::Inert(handle) => handle.kind(),
1020 }
1021 }
1022
1023 #[must_use]
1025 pub fn queue(&self, queue: impl Into<String>) -> ControllerQueueTimer<'_> {
1026 match self {
1027 Self::Active(handle) => ControllerQueueTimer::Active(handle.queue(queue)),
1028 Self::Inert(_) => ControllerQueueTimer::Inert,
1029 }
1030 }
1031
1032 #[must_use]
1034 pub fn stage(&self, stage: impl Into<String>) -> ControllerStageTimer<'_> {
1035 match self {
1036 Self::Active(handle) => ControllerStageTimer::Active(handle.stage(stage)),
1037 Self::Inert(_) => ControllerStageTimer::Inert,
1038 }
1039 }
1040
1041 #[must_use]
1043 pub fn inflight(&self, gauge: impl Into<String>) -> ControllerInflightGuard<'_> {
1044 match self {
1045 Self::Active(handle) => ControllerInflightGuard::Active(handle.inflight(gauge)),
1046 Self::Inert(_) => ControllerInflightGuard::Inert,
1047 }
1048 }
1049}
1050
1051#[derive(Debug, Clone)]
1053pub struct InertControllerRequestHandle {
1054 request_id: String,
1055 route: String,
1056 kind: Option<String>,
1057}
1058
1059impl InertControllerRequestHandle {
1060 fn new(route: String, options: RequestOptions, fallback_request_id: String) -> Self {
1061 Self {
1062 request_id: options.request_id.unwrap_or(fallback_request_id),
1063 route,
1064 kind: options.kind,
1065 }
1066 }
1067
1068 fn request_id(&self) -> &str {
1069 &self.request_id
1070 }
1071
1072 fn route(&self) -> &str {
1073 &self.route
1074 }
1075
1076 fn kind(&self) -> Option<&str> {
1077 self.kind.as_deref()
1078 }
1079}
1080
1081#[derive(Debug)]
1083pub enum ControllerQueueTimer<'a> {
1084 Active(QueueTimer<'a>),
1086 Inert,
1088}
1089
1090impl ControllerQueueTimer<'_> {
1091 #[must_use]
1093 pub fn with_depth_at_start(self, depth_at_start: u64) -> Self {
1094 match self {
1095 Self::Active(timer) => Self::Active(timer.with_depth_at_start(depth_at_start)),
1096 Self::Inert => Self::Inert,
1097 }
1098 }
1099
1100 pub async fn await_on<Fut, T>(self, fut: Fut) -> T
1102 where
1103 Fut: std::future::Future<Output = T>,
1104 {
1105 match self {
1106 Self::Active(timer) => timer.await_on(fut).await,
1107 Self::Inert => fut.await,
1108 }
1109 }
1110}
1111
1112#[derive(Debug)]
1114pub enum ControllerStageTimer<'a> {
1115 Active(StageTimer<'a>),
1117 Inert,
1119}
1120
1121impl ControllerStageTimer<'_> {
1122 pub async fn await_on<Fut, T, E>(self, fut: Fut) -> Result<T, E>
1128 where
1129 Fut: std::future::Future<Output = Result<T, E>>,
1130 {
1131 match self {
1132 Self::Active(timer) => timer.await_on(fut).await,
1133 Self::Inert => fut.await,
1134 }
1135 }
1136
1137 pub async fn await_value<Fut, T>(self, fut: Fut) -> T
1139 where
1140 Fut: std::future::Future<Output = T>,
1141 {
1142 match self {
1143 Self::Active(timer) => timer.await_value(fut).await,
1144 Self::Inert => fut.await,
1145 }
1146 }
1147}
1148
1149#[derive(Debug)]
1151pub enum ControllerInflightGuard<'a> {
1152 Active(InflightGuard<'a>),
1154 Inert,
1156}
1157
1158#[derive(Debug, Clone, PartialEq, Eq)]
1160pub struct TailtriageControllerTemplate {
1161 pub service_name: String,
1163 pub config_path: Option<PathBuf>,
1165 pub sink_template: ControllerSinkTemplate,
1167 pub selected_mode: CaptureMode,
1169 pub capture_limits_override: CaptureLimitsOverride,
1171 pub strict_lifecycle: bool,
1173 pub runtime_sampler: RuntimeSamplerTemplate,
1175 pub run_end_policy: RunEndPolicy,
1177}
1178
1179#[derive(Debug, Clone, PartialEq, Eq)]
1181pub enum ControllerSinkTemplate {
1182 LocalJson {
1184 output_path: PathBuf,
1186 },
1187}
1188
1189#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1191pub struct RuntimeSamplerTemplate {
1192 pub enabled_for_armed_runs: bool,
1194 pub mode_override: Option<CaptureMode>,
1196 pub interval_ms: Option<u64>,
1198 pub max_runtime_snapshots: Option<usize>,
1200}
1201
1202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1204pub enum RunEndPolicy {
1205 ContinueAfterLimitsHit,
1207 AutoSealOnLimitsHit,
1209}
1210
1211#[derive(Debug, Clone, PartialEq, Eq)]
1213pub struct TailtriageControllerStatus {
1214 pub template: TailtriageControllerTemplate,
1216 pub generation: GenerationState,
1218}
1219
1220#[derive(Debug, Clone, PartialEq, Eq)]
1222pub enum GenerationState {
1223 Disabled {
1225 next_generation: u64,
1227 },
1228 Active(Box<ActiveGenerationState>),
1230}
1231
1232#[derive(Debug, Clone, PartialEq, Eq)]
1234pub struct ActiveGenerationState {
1235 pub generation_id: u64,
1237 pub started_at_unix_ms: u64,
1239 pub artifact_path: PathBuf,
1241 pub accepting_new_admissions: bool,
1243 pub closing: bool,
1245 pub inflight_captured_requests: u64,
1247 pub finalization_in_progress: bool,
1249 pub last_finalize_error: Option<String>,
1255 pub activation_config: ControllerActivationTemplate,
1257}
1258
1259#[derive(Debug, Clone, PartialEq, Eq)]
1261pub struct ControllerActivationTemplate {
1262 pub sink_template: ControllerSinkTemplate,
1264 pub selected_mode: CaptureMode,
1266 pub capture_limits_override: CaptureLimitsOverride,
1268 pub strict_lifecycle: bool,
1270 pub runtime_sampler: RuntimeSamplerTemplate,
1272 pub run_end_policy: RunEndPolicy,
1274}
1275
1276#[derive(Debug)]
1277enum ControllerLifecycle {
1278 Disabled {
1279 next_generation: u64,
1280 },
1281 Active {
1282 active: Arc<ActiveGenerationRuntime>,
1283 next_generation: u64,
1284 },
1285}
1286
1287impl ControllerLifecycle {
1288 fn snapshot(&self) -> GenerationState {
1289 match self {
1290 Self::Disabled { next_generation } => GenerationState::Disabled {
1291 next_generation: *next_generation,
1292 },
1293 Self::Active { active, .. } => GenerationState::Active(Box::new(active.snapshot())),
1294 }
1295 }
1296}
1297
1298#[derive(Debug, Clone, Deserialize)]
1299struct ControllerConfigFile {
1300 controller: ControllerConfigToml,
1301}
1302
1303impl ControllerConfigFile {
1304 fn from_path(path: &Path) -> Result<Self, ConfigLoadError> {
1305 let raw = fs::read_to_string(path).map_err(|source| ConfigLoadError::Io {
1306 path: path.to_path_buf(),
1307 source,
1308 })?;
1309 toml::from_str(&raw).map_err(|source| ConfigLoadError::Parse {
1310 path: path.to_path_buf(),
1311 source: Box::new(source),
1312 })
1313 }
1314
1315 fn into_loaded(self) -> LoadedControllerConfig {
1316 let activation = self.controller.activation;
1317 let run_end_policy = activation.run_end_policy();
1318 LoadedControllerConfig {
1319 service_name: self.controller.service_name,
1320 initially_enabled: self.controller.initially_enabled,
1321 activation_template: ControllerActivationTemplate {
1322 sink_template: activation.sink.into_template(),
1323 selected_mode: activation.mode,
1324 capture_limits_override: activation.capture_limits_override,
1325 strict_lifecycle: activation.strict_lifecycle,
1326 runtime_sampler: activation.runtime_sampler,
1327 run_end_policy,
1328 },
1329 }
1330 }
1331}
1332
1333#[derive(Debug, Clone, PartialEq, Eq)]
1335pub struct LoadedControllerConfig {
1336 pub service_name: Option<String>,
1338 pub initially_enabled: Option<bool>,
1340 pub activation_template: ControllerActivationTemplate,
1342}
1343
1344#[derive(Debug, Clone, Deserialize)]
1345struct ControllerConfigToml {
1346 service_name: Option<String>,
1347 initially_enabled: Option<bool>,
1348 activation: ControllerActivationConfigToml,
1349}
1350
1351#[derive(Debug, Clone, Deserialize)]
1352struct ControllerActivationConfigToml {
1353 mode: CaptureMode,
1354 #[serde(default)]
1355 capture_limits_override: CaptureLimitsOverride,
1356 #[serde(default)]
1357 strict_lifecycle: bool,
1358 sink: ControllerSinkTemplateToml,
1359 #[serde(default)]
1360 runtime_sampler: RuntimeSamplerTemplate,
1361 #[serde(default)]
1362 run_end_policy: RunEndPolicyConfigToml,
1363}
1364
1365#[derive(Debug, Clone, Deserialize)]
1366#[serde(tag = "type", rename_all = "snake_case")]
1367enum ControllerSinkTemplateToml {
1368 LocalJson { output_path: PathBuf },
1369}
1370
1371impl ControllerSinkTemplateToml {
1372 fn into_template(self) -> ControllerSinkTemplate {
1373 match self {
1374 Self::LocalJson { output_path } => ControllerSinkTemplate::LocalJson { output_path },
1375 }
1376 }
1377}
1378
1379#[derive(Debug, Clone, Default, Deserialize)]
1380#[serde(tag = "kind", rename_all = "snake_case")]
1381enum RunEndPolicyConfigToml {
1382 #[default]
1383 ContinueAfterLimitsHit,
1384 AutoSealOnLimitsHit,
1385}
1386
1387impl From<RunEndPolicyConfigToml> for RunEndPolicy {
1388 fn from(value: RunEndPolicyConfigToml) -> Self {
1389 match value {
1390 RunEndPolicyConfigToml::ContinueAfterLimitsHit => Self::ContinueAfterLimitsHit,
1391 RunEndPolicyConfigToml::AutoSealOnLimitsHit => Self::AutoSealOnLimitsHit,
1392 }
1393 }
1394}
1395
1396impl ControllerActivationConfigToml {
1397 fn run_end_policy(&self) -> RunEndPolicy {
1398 self.run_end_policy.clone().into()
1399 }
1400}
1401
1402#[derive(Debug)]
1404pub enum ConfigLoadError {
1405 Io {
1407 path: PathBuf,
1409 source: std::io::Error,
1411 },
1412 Parse {
1414 path: PathBuf,
1416 source: Box<toml::de::Error>,
1418 },
1419}
1420
1421impl std::fmt::Display for ConfigLoadError {
1422 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1423 match self {
1424 Self::Io { path, source } => {
1425 write!(
1426 f,
1427 "failed to read controller config {}: {source}",
1428 path.display()
1429 )
1430 }
1431 Self::Parse { path, source } => {
1432 write!(
1433 f,
1434 "failed to parse controller config TOML {}: {source}",
1435 path.display()
1436 )
1437 }
1438 }
1439 }
1440}
1441
1442impl std::error::Error for ConfigLoadError {
1443 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1444 match self {
1445 Self::Io { source, .. } => Some(source),
1446 Self::Parse { source, .. } => Some(source),
1447 }
1448 }
1449}
1450
1451#[derive(Debug)]
1453pub enum ControllerBuildError {
1454 EmptyServiceName,
1456 ConfigLoad(ConfigLoadError),
1458 InitialEnable(EnableError),
1460}
1461
1462impl std::fmt::Display for ControllerBuildError {
1463 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1464 match self {
1465 Self::EmptyServiceName => write!(f, "service_name cannot be empty"),
1466 Self::ConfigLoad(err) => write!(f, "failed to load config for build: {err}"),
1467 Self::InitialEnable(err) => write!(f, "failed to start initial generation: {err}"),
1468 }
1469 }
1470}
1471
1472impl std::error::Error for ControllerBuildError {}
1473
1474#[derive(Debug)]
1476pub enum ReloadConfigError {
1477 MissingConfigPath,
1479 Load(ConfigLoadError),
1481 Validate(BuildError),
1483}
1484
1485impl std::fmt::Display for ReloadConfigError {
1486 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1487 match self {
1488 Self::MissingConfigPath => write!(f, "controller has no config_path; cannot reload"),
1489 Self::Load(err) => write!(f, "failed to reload controller config: {err}"),
1490 Self::Validate(err) => {
1491 write!(f, "reloaded config did not produce a valid template: {err}")
1492 }
1493 }
1494 }
1495}
1496
1497impl std::error::Error for ReloadConfigError {
1498 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1499 match self {
1500 Self::MissingConfigPath => None,
1501 Self::Load(err) => Some(err),
1502 Self::Validate(err) => Some(err),
1503 }
1504 }
1505}
1506
1507#[derive(Debug)]
1509pub enum ReloadTemplateError {
1510 Validate(BuildError),
1512}
1513
1514impl std::fmt::Display for ReloadTemplateError {
1515 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1516 match self {
1517 Self::Validate(err) => write!(f, "template is invalid: {err}"),
1518 }
1519 }
1520}
1521
1522impl std::error::Error for ReloadTemplateError {
1523 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1524 match self {
1525 Self::Validate(err) => Some(err),
1526 }
1527 }
1528}
1529
1530#[derive(Debug)]
1532pub enum EnableError {
1533 AlreadyActive {
1535 generation_id: u64,
1537 },
1538 Build(BuildError),
1540 MissingTokioRuntimeForSampler,
1542 StartRuntimeSampler(SamplerStartError),
1544}
1545
1546impl std::fmt::Display for EnableError {
1547 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1548 match self {
1549 Self::AlreadyActive { generation_id } => {
1550 write!(f, "generation {generation_id} is already active")
1551 }
1552 Self::Build(err) => write!(f, "failed to build generation run: {err}"),
1553 Self::MissingTokioRuntimeForSampler => {
1554 write!(f, "runtime sampler requires an active Tokio runtime")
1555 }
1556 Self::StartRuntimeSampler(err) => {
1557 write!(f, "failed to start runtime sampler for generation: {err}")
1558 }
1559 }
1560 }
1561}
1562
1563impl std::error::Error for EnableError {}
1564
1565#[derive(Debug)]
1567pub enum DisableError {
1568 Finalize(tailtriage_core::SinkError),
1570}
1571
1572impl std::fmt::Display for DisableError {
1573 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1574 match self {
1575 Self::Finalize(err) => write!(f, "failed to finalize generation: {err}"),
1576 }
1577 }
1578}
1579
1580impl std::error::Error for DisableError {}
1581
1582#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1584pub enum DisableOutcome {
1585 AlreadyDisabled,
1587 Closing {
1589 generation_id: u64,
1591 inflight_captured_requests: u64,
1593 },
1594 Finalized {
1596 generation_id: u64,
1598 },
1599}
1600
1601#[derive(Debug)]
1603pub enum ShutdownError {
1604 Finalize(DisableError),
1606}
1607
1608impl std::fmt::Display for ShutdownError {
1609 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1610 match self {
1611 Self::Finalize(err) => write!(f, "shutdown finalization failed: {err}"),
1612 }
1613 }
1614}
1615
1616impl std::error::Error for ShutdownError {}
1617
1618fn generated_artifact_path(template: &ControllerSinkTemplate, generation_id: u64) -> PathBuf {
1619 match template {
1620 ControllerSinkTemplate::LocalJson { output_path } => {
1621 let parent = output_path
1622 .parent()
1623 .map(Path::to_path_buf)
1624 .unwrap_or_default();
1625 let stem = output_path
1626 .file_stem()
1627 .and_then(std::ffi::OsStr::to_str)
1628 .unwrap_or("tailtriage-run");
1629 let extension = output_path.extension().and_then(std::ffi::OsStr::to_str);
1630 let filename = match extension {
1631 Some(ext) if !ext.is_empty() => format!("{stem}-generation-{generation_id}.{ext}"),
1632 _ => format!("{stem}-generation-{generation_id}.json"),
1633 };
1634 parent.join(filename)
1635 }
1636 }
1637}
1638
1639#[cfg(test)]
1640mod tests {
1641 use std::fs;
1642 use std::path::{Path, PathBuf};
1643 use std::sync::Arc;
1644 use std::time::Duration;
1645
1646 use super::{
1647 ControllerBuildError, ControllerSinkTemplate, DisableOutcome, EnableError, GenerationState,
1648 ReloadConfigError, ReloadTemplateError, RunEndPolicy, RuntimeSamplerTemplate,
1649 TailtriageController, TailtriageControllerTemplate,
1650 };
1651 use serde::Serialize;
1652 use tailtriage_core::{
1653 CaptureLimitsOverride, CaptureMode, RequestOptions, Run, RuntimeSnapshot,
1654 };
1655
1656 #[derive(Serialize)]
1657 struct TestControllerConfigToml {
1658 controller: TestControllerConfigBodyToml,
1659 }
1660
1661 #[derive(Serialize)]
1662 struct TestControllerConfigBodyToml {
1663 #[serde(skip_serializing_if = "Option::is_none")]
1664 service_name: Option<String>,
1665 #[serde(skip_serializing_if = "Option::is_none")]
1666 initially_enabled: Option<bool>,
1667 activation: TestActivationToml,
1668 }
1669
1670 #[derive(Serialize)]
1671 struct TestActivationToml {
1672 mode: &'static str,
1673 #[serde(skip_serializing_if = "Option::is_none")]
1674 capture_limits_override: Option<TestCaptureLimitsOverrideToml>,
1675 #[serde(skip_serializing_if = "Option::is_none")]
1676 strict_lifecycle: Option<bool>,
1677 sink: TestSinkToml,
1678 #[serde(skip_serializing_if = "Option::is_none")]
1679 runtime_sampler: Option<TestRuntimeSamplerToml>,
1680 #[serde(skip_serializing_if = "Option::is_none")]
1681 run_end_policy: Option<TestRunEndPolicyToml>,
1682 }
1683
1684 #[derive(Serialize)]
1685 struct TestCaptureLimitsOverrideToml {
1686 #[serde(skip_serializing_if = "Option::is_none")]
1687 max_requests: Option<u64>,
1688 #[serde(skip_serializing_if = "Option::is_none")]
1689 max_stages: Option<u64>,
1690 }
1691
1692 #[derive(Serialize)]
1693 struct TestSinkToml {
1694 #[serde(rename = "type")]
1695 sink_type: &'static str,
1696 output_path: PathBuf,
1697 }
1698
1699 #[derive(Serialize)]
1700 struct TestRuntimeSamplerToml {
1701 enabled_for_armed_runs: bool,
1702 mode_override: &'static str,
1703 interval_ms: u64,
1704 max_runtime_snapshots: u64,
1705 }
1706
1707 #[derive(Serialize)]
1708 struct TestRunEndPolicyToml {
1709 kind: &'static str,
1710 }
1711
1712 fn test_output(base: &str) -> std::path::PathBuf {
1713 let unique = format!(
1714 "tailtriage-controller-{base}-{}-{}.json",
1715 std::process::id(),
1716 tailtriage_core::unix_time_ms()
1717 );
1718 std::env::temp_dir().join(unique)
1719 }
1720
1721 fn read_artifact(path: &std::path::Path) -> String {
1722 fs::read_to_string(path).expect("artifact should be readable")
1723 }
1724
1725 fn read_run(path: &std::path::Path) -> Run {
1726 let artifact = read_artifact(path);
1727 serde_json::from_str(&artifact).expect("artifact should parse as Run")
1728 }
1729
1730 fn active_runtime(controller: &TailtriageController) -> Arc<super::ActiveGenerationRuntime> {
1731 let lifecycle = controller
1732 .inner
1733 .lifecycle
1734 .lock()
1735 .expect("controller lifecycle lock poisoned");
1736 let super::ControllerLifecycle::Active { active, .. } = &*lifecycle else {
1737 panic!("expected active generation");
1738 };
1739 Arc::clone(active)
1740 }
1741
1742 fn test_config_path(base: &str) -> std::path::PathBuf {
1743 let unique = format!(
1744 "tailtriage-controller-config-{base}-{}-{}.toml",
1745 std::process::id(),
1746 tailtriage_core::unix_time_ms()
1747 );
1748 std::env::temp_dir().join(unique)
1749 }
1750
1751 fn write_config(
1752 path: &Path,
1753 output: &Path,
1754 mode: &'static str,
1755 strict: bool,
1756 sampler_enabled: bool,
1757 ) {
1758 let content = toml::to_string(&TestControllerConfigToml {
1759 controller: TestControllerConfigBodyToml {
1760 service_name: None,
1761 initially_enabled: Some(false),
1762 activation: TestActivationToml {
1763 mode,
1764 capture_limits_override: Some(TestCaptureLimitsOverrideToml {
1765 max_requests: Some(17),
1766 max_stages: Some(18),
1767 }),
1768 strict_lifecycle: Some(strict),
1769 sink: TestSinkToml {
1770 sink_type: "local_json",
1771 output_path: output.to_path_buf(),
1772 },
1773 runtime_sampler: Some(TestRuntimeSamplerToml {
1774 enabled_for_armed_runs: sampler_enabled,
1775 mode_override: "investigation",
1776 interval_ms: 250,
1777 max_runtime_snapshots: 123,
1778 }),
1779 run_end_policy: Some(TestRunEndPolicyToml {
1780 kind: "auto_seal_on_limits_hit",
1781 }),
1782 },
1783 },
1784 })
1785 .expect("config TOML serialization should succeed");
1786 fs::write(path, content).expect("config write should succeed");
1787 }
1788
1789 fn write_initially_enabled_config(path: &Path, output: &Path) {
1790 let content = toml::to_string(&TestControllerConfigToml {
1791 controller: TestControllerConfigBodyToml {
1792 service_name: Some("toml-service-name".to_owned()),
1793 initially_enabled: Some(true),
1794 activation: TestActivationToml {
1795 mode: "investigation",
1796 capture_limits_override: Some(TestCaptureLimitsOverrideToml {
1797 max_requests: Some(9),
1798 max_stages: None,
1799 }),
1800 strict_lifecycle: Some(true),
1801 sink: TestSinkToml {
1802 sink_type: "local_json",
1803 output_path: output.to_path_buf(),
1804 },
1805 runtime_sampler: None,
1806 run_end_policy: Some(TestRunEndPolicyToml {
1807 kind: "auto_seal_on_limits_hit",
1808 }),
1809 },
1810 },
1811 })
1812 .expect("config TOML serialization should succeed");
1813 fs::write(path, content).expect("config write should succeed");
1814 }
1815
1816 fn write_sparse_config(path: &Path, output: &Path, mode: &'static str) {
1817 let content = toml::to_string(&TestControllerConfigToml {
1818 controller: TestControllerConfigBodyToml {
1819 service_name: None,
1820 initially_enabled: None,
1821 activation: TestActivationToml {
1822 mode,
1823 capture_limits_override: None,
1824 strict_lifecycle: None,
1825 sink: TestSinkToml {
1826 sink_type: "local_json",
1827 output_path: output.to_path_buf(),
1828 },
1829 runtime_sampler: None,
1830 run_end_policy: None,
1831 },
1832 },
1833 })
1834 .expect("config TOML serialization should succeed");
1835 fs::write(path, content).expect("config write should succeed");
1836 }
1837
1838 fn write_config_with_optional_service_name(
1839 path: &Path,
1840 output: &Path,
1841 service_name: Option<&str>,
1842 ) {
1843 let content = toml::to_string(&TestControllerConfigToml {
1844 controller: TestControllerConfigBodyToml {
1845 service_name: service_name.map(str::to_owned),
1846 initially_enabled: Some(false),
1847 activation: TestActivationToml {
1848 mode: "light",
1849 capture_limits_override: None,
1850 strict_lifecycle: None,
1851 sink: TestSinkToml {
1852 sink_type: "local_json",
1853 output_path: output.to_path_buf(),
1854 },
1855 runtime_sampler: None,
1856 run_end_policy: None,
1857 },
1858 },
1859 })
1860 .expect("config TOML serialization should succeed");
1861 fs::write(path, content).expect("config write should succeed");
1862 }
1863
1864 fn write_raw_config(path: &std::path::Path, content: &str) {
1865 fs::write(path, content).expect("config write should succeed");
1866 }
1867
1868 #[test]
1869 fn enable_capture_disable_finalizes_generation() {
1870 let output = test_output("enable-capture-disable");
1871 let controller = TailtriageController::builder("checkout-service")
1872 .output(&output)
1873 .build()
1874 .expect("build should succeed");
1875
1876 let active = controller.enable().expect("enable should succeed");
1877 let started = controller.begin_request("/checkout");
1878 started.completion.finish_ok();
1879
1880 let disable = controller.disable().expect("disable should succeed");
1881 assert!(matches!(
1882 disable,
1883 DisableOutcome::Finalized {
1884 generation_id: id
1885 } if id == active.generation_id
1886 ));
1887
1888 let expected = output.with_file_name(format!(
1889 "{}-generation-1.json",
1890 output
1891 .file_stem()
1892 .and_then(std::ffi::OsStr::to_str)
1893 .expect("stem")
1894 ));
1895 assert!(expected.exists());
1896
1897 fs::remove_file(expected).expect("cleanup should succeed");
1898 }
1899
1900 #[test]
1901 fn active_generation_started_at_matches_underlying_run_metadata() {
1902 let output = test_output("generation-started-at-run-metadata");
1903 let controller = TailtriageController::builder("checkout-service")
1904 .output(&output)
1905 .build()
1906 .expect("build should succeed");
1907
1908 let active = controller.enable().expect("enable should succeed");
1909 let runtime = active_runtime(&controller);
1910 let run_started_at = runtime.run.snapshot().metadata.started_at_unix_ms;
1911
1912 assert_eq!(active.started_at_unix_ms, run_started_at);
1913 assert_eq!(runtime.state.started_at_unix_ms, run_started_at);
1914
1915 assert!(matches!(
1916 controller.disable(),
1917 Ok(DisableOutcome::Finalized { generation_id: 1 })
1918 ));
1919 fs::remove_file(active.artifact_path).expect("cleanup should succeed");
1920 }
1921
1922 #[test]
1923 fn initially_enabled_build_starts_first_active_generation() {
1924 let output = test_output("initially-enabled");
1925 let controller = TailtriageController::builder("checkout-service")
1926 .initially_enabled(true)
1927 .output(&output)
1928 .build()
1929 .expect("build should succeed");
1930
1931 let status = controller.status();
1932 let active = match status.generation {
1933 GenerationState::Active(active) => active,
1934 disabled @ GenerationState::Disabled { .. } => {
1935 panic!("expected active generation after build, got {disabled:?}")
1936 }
1937 };
1938 assert_eq!(active.generation_id, 1);
1939
1940 assert!(matches!(
1941 controller.disable(),
1942 Ok(DisableOutcome::Finalized { generation_id: 1 })
1943 ));
1944 fs::remove_file(active.artifact_path).expect("cleanup should succeed");
1945 }
1946
1947 #[test]
1948 fn disabled_status_reports_next_generation() {
1949 let controller = TailtriageController::builder("checkout-service")
1950 .build()
1951 .expect("build should succeed");
1952
1953 assert!(matches!(
1954 controller.status().generation,
1955 GenerationState::Disabled { next_generation: 1 }
1956 ));
1957 }
1958
1959 #[test]
1960 fn enable_disable_reenable_creates_distinct_generation_and_artifact() {
1961 let output = test_output("reenable");
1962 let controller = TailtriageController::builder("checkout-service")
1963 .output(&output)
1964 .build()
1965 .expect("build should succeed");
1966
1967 let first = controller.enable().expect("first enable should succeed");
1968 assert!(matches!(
1969 controller.disable(),
1970 Ok(DisableOutcome::Finalized { generation_id: 1 })
1971 ));
1972
1973 let second = controller.enable().expect("second enable should succeed");
1974 assert_eq!(first.generation_id + 1, second.generation_id);
1975 assert_ne!(first.artifact_path, second.artifact_path);
1976
1977 assert!(matches!(
1978 controller.disable(),
1979 Ok(DisableOutcome::Finalized { generation_id: 2 })
1980 ));
1981
1982 fs::remove_file(first.artifact_path).expect("cleanup first artifact should succeed");
1983 fs::remove_file(second.artifact_path).expect("cleanup second artifact should succeed");
1984 }
1985
1986 #[test]
1987 fn request_started_before_disable_can_finish_after_disable() {
1988 let output = test_output("finish-after-disable");
1989 let controller = TailtriageController::builder("checkout-service")
1990 .output(&output)
1991 .build()
1992 .expect("build should succeed");
1993
1994 let active = controller.enable().expect("enable should succeed");
1995 let started = controller.begin_request("/checkout");
1996
1997 let disable = controller.disable().expect("disable should succeed");
1998 assert!(matches!(
1999 disable,
2000 DisableOutcome::Closing {
2001 generation_id,
2002 inflight_captured_requests: 1
2003 } if generation_id == active.generation_id
2004 ));
2005
2006 started.completion.finish_ok();
2007
2008 let status = controller.status();
2009 assert!(matches!(
2010 status.generation,
2011 GenerationState::Disabled { next_generation: 2 }
2012 ));
2013 assert!(active.artifact_path.exists());
2014
2015 fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2016 }
2017
2018 #[test]
2019 fn no_new_admissions_after_disable() {
2020 let output = test_output("no-admissions");
2021 let controller = TailtriageController::builder("checkout-service")
2022 .output(&output)
2023 .build()
2024 .expect("build should succeed");
2025
2026 let active = controller.enable().expect("enable should succeed");
2027 let started = controller.begin_request("/checkout");
2028
2029 let _ = controller.disable().expect("disable should succeed");
2030
2031 controller.begin_request("/checkout").completion.finish_ok();
2032
2033 started.completion.finish_ok();
2034 fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2035 }
2036
2037 #[test]
2038 fn default_policy_preserves_cheap_drop_after_saturation() {
2039 let output = test_output("default-policy-cheap-drop");
2040 let controller = TailtriageController::builder("checkout-service")
2041 .output(&output)
2042 .capture_limits_override(CaptureLimitsOverride {
2043 max_requests: Some(1),
2044 ..CaptureLimitsOverride::default()
2045 })
2046 .build()
2047 .expect("build should succeed");
2048
2049 let active = controller.enable().expect("enable should succeed");
2050 controller.begin_request("/checkout").completion.finish_ok();
2051 controller.begin_request("/checkout").completion.finish_ok();
2052 controller.begin_request("/checkout").completion.finish_ok();
2053
2054 let status = controller.status();
2055 let GenerationState::Active(active_status) = status.generation else {
2056 panic!("default policy should keep generation active after saturation");
2057 };
2058 assert!(active_status.accepting_new_admissions);
2059 assert!(!active_status.closing);
2060
2061 assert!(matches!(
2062 controller.disable(),
2063 Ok(DisableOutcome::Finalized { generation_id }) if generation_id == active.generation_id
2064 ));
2065
2066 let run = read_run(&active.artifact_path);
2067 assert!(run.truncation.limits_hit);
2068 assert_eq!(run.truncation.dropped_requests, 2);
2069 assert_eq!(
2070 run.metadata.run_end_reason,
2071 Some(tailtriage_core::RunEndReason::ManualDisarm)
2072 );
2073
2074 fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2075 }
2076
2077 #[test]
2078 fn auto_seal_policy_ends_generation_after_limits_hit() {
2079 let output = test_output("auto-seal-policy");
2080 let controller = TailtriageController::builder("checkout-service")
2081 .output(&output)
2082 .run_end_policy(RunEndPolicy::AutoSealOnLimitsHit)
2083 .capture_limits_override(CaptureLimitsOverride {
2084 max_requests: Some(1),
2085 ..CaptureLimitsOverride::default()
2086 })
2087 .build()
2088 .expect("build should succeed");
2089
2090 let active = controller.enable().expect("enable should succeed");
2091 controller.begin_request("/checkout").completion.finish_ok();
2092 controller.begin_request("/checkout").completion.finish_ok();
2093
2094 let status = controller.status();
2095 assert!(matches!(
2096 status.generation,
2097 GenerationState::Disabled { next_generation: 2 }
2098 ));
2099
2100 let run = read_run(&active.artifact_path);
2101 assert!(run.truncation.limits_hit);
2102 assert!(run.truncation.dropped_requests > 0);
2103 assert_eq!(
2104 run.metadata.run_end_reason,
2105 Some(tailtriage_core::RunEndReason::AutoSealOnLimitsHit)
2106 );
2107
2108 fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2109 }
2110
2111 #[test]
2112 fn runtime_snapshot_saturation_triggers_auto_seal() {
2113 let output = test_output("auto-seal-runtime-snapshot");
2114 let controller = TailtriageController::builder("checkout-service")
2115 .output(&output)
2116 .run_end_policy(RunEndPolicy::AutoSealOnLimitsHit)
2117 .capture_limits_override(CaptureLimitsOverride {
2118 max_runtime_snapshots: Some(1),
2119 ..CaptureLimitsOverride::default()
2120 })
2121 .build()
2122 .expect("build should succeed");
2123
2124 let active = controller.enable().expect("enable should succeed");
2125 let runtime = active_runtime(&controller);
2126
2127 runtime.run.record_runtime_snapshot(RuntimeSnapshot {
2128 at_unix_ms: tailtriage_core::unix_time_ms(),
2129 at_run_us: None,
2130 alive_tasks: Some(1),
2131 global_queue_depth: Some(1),
2132 local_queue_depth: Some(1),
2133 blocking_queue_depth: Some(0),
2134 remote_schedule_count: Some(1),
2135 });
2136 runtime.run.record_runtime_snapshot(RuntimeSnapshot {
2137 at_unix_ms: tailtriage_core::unix_time_ms(),
2138 at_run_us: None,
2139 alive_tasks: Some(2),
2140 global_queue_depth: Some(2),
2141 local_queue_depth: Some(2),
2142 blocking_queue_depth: Some(0),
2143 remote_schedule_count: Some(2),
2144 });
2145
2146 assert!(matches!(
2147 controller.status().generation,
2148 GenerationState::Disabled { next_generation: 2 }
2149 ));
2150 let run = read_run(&active.artifact_path);
2151 assert!(run.truncation.limits_hit);
2152 assert!(run.truncation.dropped_runtime_snapshots > 0);
2153 assert_eq!(
2154 run.metadata.run_end_reason,
2155 Some(tailtriage_core::RunEndReason::AutoSealOnLimitsHit)
2156 );
2157
2158 fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2159 }
2160
2161 #[tokio::test(flavor = "current_thread")]
2162 async fn queue_saturation_triggers_auto_seal_and_waits_for_inflight_drain() {
2163 let output = test_output("auto-seal-queue-saturation");
2164 let controller = TailtriageController::builder("checkout-service")
2165 .output(&output)
2166 .run_end_policy(RunEndPolicy::AutoSealOnLimitsHit)
2167 .capture_limits_override(CaptureLimitsOverride {
2168 max_queues: Some(1),
2169 ..CaptureLimitsOverride::default()
2170 })
2171 .build()
2172 .expect("build should succeed");
2173
2174 let active = controller.enable().expect("enable should succeed");
2175 let started = controller.begin_request("/checkout");
2176 let request = started.handle.clone();
2177 request
2178 .queue("primary")
2179 .with_depth_at_start(1)
2180 .await_on(async {})
2181 .await;
2182 request
2183 .queue("primary")
2184 .with_depth_at_start(2)
2185 .await_on(async {})
2186 .await;
2187
2188 let status = controller.status();
2189 let GenerationState::Active(active_status) = status.generation else {
2190 panic!("generation should remain active while admitted request is still in-flight");
2191 };
2192 assert!(active_status.closing);
2193 assert!(!active_status.accepting_new_admissions);
2194
2195 started.completion.finish_ok();
2196
2197 assert!(matches!(
2198 controller.status().generation,
2199 GenerationState::Disabled { next_generation: 2 }
2200 ));
2201 let run = read_run(&active.artifact_path);
2202 assert!(run.truncation.limits_hit);
2203 assert!(run.truncation.dropped_queues > 0);
2204 assert_eq!(
2205 run.metadata.run_end_reason,
2206 Some(tailtriage_core::RunEndReason::AutoSealOnLimitsHit)
2207 );
2208
2209 fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2210 }
2211
2212 #[test]
2213 fn auto_seal_then_next_enable_creates_fresh_generation() {
2214 let output = test_output("auto-seal-next-generation");
2215 let controller = TailtriageController::builder("checkout-service")
2216 .output(&output)
2217 .run_end_policy(RunEndPolicy::AutoSealOnLimitsHit)
2218 .capture_limits_override(CaptureLimitsOverride {
2219 max_requests: Some(1),
2220 ..CaptureLimitsOverride::default()
2221 })
2222 .build()
2223 .expect("build should succeed");
2224
2225 let first = controller.enable().expect("first enable should succeed");
2226 controller.begin_request("/checkout").completion.finish_ok();
2227 controller.begin_request("/checkout").completion.finish_ok();
2228 assert!(matches!(
2229 controller.status().generation,
2230 GenerationState::Disabled { next_generation: 2 }
2231 ));
2232
2233 let second = controller.enable().expect("second enable should succeed");
2234 assert_eq!(second.generation_id, first.generation_id + 1);
2235 controller.begin_request("/checkout").completion.finish_ok();
2236 assert!(matches!(
2237 controller.disable(),
2238 Ok(DisableOutcome::Finalized { generation_id }) if generation_id == second.generation_id
2239 ));
2240
2241 fs::remove_file(first.artifact_path).expect("cleanup first should succeed");
2242 fs::remove_file(second.artifact_path).expect("cleanup second should succeed");
2243 }
2244
2245 #[test]
2246 fn one_active_generation_at_a_time() {
2247 let controller = TailtriageController::builder("checkout-service")
2248 .build()
2249 .expect("build should succeed");
2250
2251 let first = controller.enable().expect("first enable should succeed");
2252 let err = controller
2253 .enable()
2254 .expect_err("second enable should fail while first generation active");
2255
2256 assert!(matches!(
2257 err,
2258 EnableError::AlreadyActive {
2259 generation_id
2260 } if generation_id == first.generation_id
2261 ));
2262
2263 assert!(matches!(
2264 controller.disable(),
2265 Ok(DisableOutcome::Finalized { .. })
2266 ));
2267 fs::remove_file(first.artifact_path).expect("cleanup should succeed");
2268 }
2269
2270 #[test]
2271 fn request_completion_remains_bound_to_original_generation_after_reenable() {
2272 let output = test_output("generation-binding");
2273 let controller = TailtriageController::builder("checkout-service")
2274 .output(&output)
2275 .build()
2276 .expect("build should succeed");
2277
2278 let gen_a = controller.enable().expect("generation A should enable");
2279 let started_a = controller.begin_request_with(
2280 "/checkout",
2281 RequestOptions::new().request_id("req-generation-a"),
2282 );
2283
2284 assert!(matches!(
2285 controller.disable(),
2286 Ok(DisableOutcome::Closing {
2287 generation_id,
2288 inflight_captured_requests: 1
2289 }) if generation_id == gen_a.generation_id
2290 ));
2291
2292 started_a.completion.finish_ok();
2293
2294 let gen_b = controller.enable().expect("generation B should enable");
2295 let started_b = controller.begin_request_with(
2296 "/checkout",
2297 RequestOptions::new().request_id("req-generation-b"),
2298 );
2299 started_b.completion.finish_ok();
2300 assert!(matches!(
2301 controller.disable(),
2302 Ok(DisableOutcome::Finalized { generation_id })
2303 if generation_id == gen_b.generation_id
2304 ));
2305
2306 let run_a = read_artifact(&gen_a.artifact_path);
2307 let run_b = read_artifact(&gen_b.artifact_path);
2308 assert!(run_a.contains("req-generation-a"));
2309 assert!(!run_a.contains("req-generation-b"));
2310 assert!(run_b.contains("req-generation-b"));
2311 assert!(!run_b.contains("req-generation-a"));
2312
2313 fs::remove_file(gen_a.artifact_path).expect("cleanup generation A should succeed");
2314 fs::remove_file(gen_b.artifact_path).expect("cleanup generation B should succeed");
2315 }
2316
2317 #[test]
2318 fn disabled_begin_request_is_inert_and_never_joins_later_generation() {
2319 let output = test_output("disabled-admission");
2320 let controller = TailtriageController::builder("checkout-service")
2321 .output(&output)
2322 .build()
2323 .expect("build should succeed");
2324
2325 let disabled_started = controller.begin_request_with(
2326 "/checkout",
2327 RequestOptions::new().request_id("req-disabled"),
2328 );
2329 assert_eq!(disabled_started.handle.request_id(), "req-disabled");
2330 disabled_started.completion.finish_ok();
2331
2332 let active = controller.enable().expect("enable should succeed");
2333 let started = controller
2334 .begin_request_with("/checkout", RequestOptions::new().request_id("req-enabled"));
2335 started.completion.finish_ok();
2336 assert!(matches!(
2337 controller.disable(),
2338 Ok(DisableOutcome::Finalized { generation_id }) if generation_id == active.generation_id
2339 ));
2340
2341 let run = read_artifact(&active.artifact_path);
2342 assert!(run.contains("req-enabled"));
2343 assert!(!run.contains("req-disabled"));
2344
2345 fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2346 }
2347
2348 #[test]
2349 fn disabled_handle_and_completion_operations_are_noop() {
2350 let output = test_output("disabled-noop");
2351 let controller = TailtriageController::builder("checkout-service")
2352 .output(&output)
2353 .build()
2354 .expect("build should succeed");
2355
2356 let started = controller.begin_request_with(
2357 "/checkout",
2358 RequestOptions::new()
2359 .request_id("req-disabled-noop")
2360 .kind("http"),
2361 );
2362
2363 assert_eq!(started.handle.request_id(), "req-disabled-noop");
2364 assert_eq!(started.handle.route(), "/checkout");
2365 assert_eq!(started.handle.kind(), Some("http"));
2366 let request = started.handle.clone();
2367 let _inflight = request.inflight("inflight-disabled");
2368 let _queue = request.queue("queue-disabled");
2369 let _stage = request.stage("stage-disabled");
2370 started
2371 .completion
2372 .finish_result::<(), &str>(Err("disabled-result"))
2373 .expect_err("disabled result should pass through unchanged");
2374
2375 let active = controller.enable().expect("enable should succeed");
2376 let enabled_started = controller
2377 .begin_request_with("/checkout", RequestOptions::new().request_id("req-enabled"));
2378 enabled_started.completion.finish_ok();
2379 assert!(matches!(
2380 controller.disable(),
2381 Ok(DisableOutcome::Finalized { generation_id }) if generation_id == active.generation_id
2382 ));
2383
2384 let run = read_artifact(&active.artifact_path);
2385 assert!(run.contains("req-enabled"));
2386 assert!(!run.contains("req-disabled-noop"));
2387
2388 fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2389 }
2390
2391 #[test]
2392 fn inert_disabled_request_id_contract_preserves_explicit_and_generates_fallback() {
2393 let output = test_output("inert-disabled-request-id");
2394 let controller = TailtriageController::builder("checkout-service")
2395 .output(&output)
2396 .build()
2397 .expect("build should succeed");
2398
2399 let explicit = controller.begin_request_with(
2400 "/checkout",
2401 RequestOptions::new().request_id("req-disabled-explicit"),
2402 );
2403 assert_eq!(explicit.handle.request_id(), "req-disabled-explicit");
2404
2405 let implicit_a = controller.begin_request("/checkout");
2406 let implicit_b = controller.begin_request("/checkout");
2407 assert!(implicit_a.handle.request_id().starts_with("inert-"));
2408 assert!(implicit_b.handle.request_id().starts_with("inert-"));
2409 assert_ne!(
2410 implicit_a.handle.request_id(),
2411 implicit_b.handle.request_id()
2412 );
2413 }
2414
2415 #[test]
2416 fn inert_closing_request_id_contract_preserves_explicit_and_generates_fallback() {
2417 let output = test_output("inert-closing-request-id");
2418 let controller = TailtriageController::builder("checkout-service")
2419 .output(&output)
2420 .build()
2421 .expect("build should succeed");
2422
2423 let active = controller.enable().expect("enable should succeed");
2424 let admitted = controller.begin_request("/checkout");
2425 assert!(matches!(
2426 controller.disable(),
2427 Ok(DisableOutcome::Closing { .. })
2428 ));
2429
2430 let explicit = controller.begin_request_with(
2431 "/checkout",
2432 RequestOptions::new().request_id("req-closing-explicit"),
2433 );
2434 assert_eq!(explicit.handle.request_id(), "req-closing-explicit");
2435
2436 let implicit = controller.begin_request("/checkout");
2437 assert!(implicit.handle.request_id().starts_with("inert-"));
2438
2439 admitted.completion.finish_ok();
2440 assert!(matches!(
2441 controller.status().generation,
2442 GenerationState::Disabled { .. }
2443 ));
2444 fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2445 }
2446
2447 #[test]
2448 fn rapid_enable_disable_boundaries_keep_generation_isolation() {
2449 let output = test_output("rapid-boundaries");
2450 let controller = TailtriageController::builder("checkout-service")
2451 .output(&output)
2452 .build()
2453 .expect("build should succeed");
2454
2455 let mut artifacts = Vec::new();
2456 for generation in 1..=3 {
2457 let active = controller.enable().expect("enable should succeed");
2458 assert_eq!(active.generation_id, generation);
2459
2460 let started = controller.begin_request_with(
2461 "/checkout",
2462 RequestOptions::new().request_id(format!("req-gen-{generation}")),
2463 );
2464
2465 assert!(matches!(
2466 controller.disable(),
2467 Ok(DisableOutcome::Closing {
2468 generation_id,
2469 inflight_captured_requests: 1
2470 }) if generation_id == generation
2471 ));
2472
2473 assert!(
2474 matches!(
2475 controller.enable(),
2476 Err(EnableError::AlreadyActive { generation_id }) if generation_id == generation
2477 ),
2478 "controller must not start next generation before admitted requests drain"
2479 );
2480
2481 started.completion.finish_ok();
2482 artifacts.push(active.artifact_path);
2483 }
2484
2485 for (idx, artifact) in artifacts.iter().enumerate() {
2486 let run = read_artifact(artifact);
2487 assert!(run.contains(&format!("req-gen-{}", idx + 1)));
2488 fs::remove_file(artifact).expect("cleanup should succeed");
2489 }
2490 }
2491
2492 #[test]
2493 fn completion_drain_finalizes_once_without_duplicate_side_effects() {
2494 let output = test_output("single-finalize");
2495 let controller = TailtriageController::builder("checkout-service")
2496 .output(&output)
2497 .build()
2498 .expect("build should succeed");
2499
2500 let active = controller.enable().expect("enable should succeed");
2501 let started = controller
2502 .begin_request_with("/checkout", RequestOptions::new().request_id("req-once"));
2503
2504 assert!(matches!(
2505 controller.disable(),
2506 Ok(DisableOutcome::Closing {
2507 generation_id,
2508 inflight_captured_requests: 1
2509 }) if generation_id == active.generation_id
2510 ));
2511
2512 started.completion.finish_ok();
2513 assert!(matches!(
2514 controller.disable(),
2515 Ok(DisableOutcome::AlreadyDisabled)
2516 ));
2517 assert!(matches!(controller.shutdown(), Ok(())));
2518
2519 let run = read_artifact(&active.artifact_path);
2520 assert_eq!(run.matches("req-once").count(), 1);
2521
2522 fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2523 }
2524
2525 #[test]
2526 fn shutdown_active_generation_finalizes_and_disables_even_with_inflight_request() {
2527 let output = test_output("shutdown-active");
2528 let controller = TailtriageController::builder("checkout-service")
2529 .output(&output)
2530 .build()
2531 .expect("build should succeed");
2532
2533 let active = controller.enable().expect("enable should succeed");
2534 let started = controller.begin_request_with(
2535 "/checkout",
2536 RequestOptions::new().request_id("req-inflight-shutdown"),
2537 );
2538
2539 controller.shutdown().expect("shutdown should succeed");
2540 assert!(matches!(
2541 controller.status().generation,
2542 GenerationState::Disabled { next_generation: 2 }
2543 ));
2544 assert!(active.artifact_path.exists());
2545
2546 let run = read_run(&active.artifact_path);
2547 assert_eq!(
2548 run.metadata.run_end_reason,
2549 Some(tailtriage_core::RunEndReason::Shutdown)
2550 );
2551
2552 controller
2553 .begin_request_with(
2554 "/checkout",
2555 RequestOptions::new().request_id("req-post-shutdown"),
2556 )
2557 .completion
2558 .finish_ok();
2559
2560 let run_after = read_artifact(&active.artifact_path);
2561 assert!(!run_after.contains("req-post-shutdown"));
2562
2563 started.completion.finish_ok();
2564 fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2565 }
2566
2567 #[test]
2568 fn drain_finalization_sink_failure_is_observable_and_retriable() {
2569 let output = std::env::temp_dir().join(format!(
2570 "tailtriage-controller-missing-dir-{}-{}",
2571 std::process::id(),
2572 tailtriage_core::unix_time_ms()
2573 ));
2574 let missing_output = output.join("artifact.json");
2575 let controller = TailtriageController::builder("checkout-service")
2576 .output(&missing_output)
2577 .build()
2578 .expect("build should succeed");
2579
2580 let active = controller.enable().expect("enable should succeed");
2581 let started = controller.begin_request("/checkout");
2582 assert!(matches!(
2583 controller.disable(),
2584 Ok(DisableOutcome::Closing {
2585 generation_id,
2586 inflight_captured_requests: 1
2587 }) if generation_id == active.generation_id
2588 ));
2589
2590 started.completion.finish_ok();
2591
2592 let status = controller.status();
2593 let GenerationState::Active(active_state) = status.generation else {
2594 panic!("generation should stay active after failed drain finalization");
2595 };
2596 assert!(active_state.closing);
2597 assert!(!active_state.accepting_new_admissions);
2598 assert!(!active_state.finalization_in_progress);
2599 let first_error = active_state
2600 .last_finalize_error
2601 .expect("failed drain finalization should be recorded");
2602 assert!(
2603 first_error.contains("failed to finalize generation"),
2604 "unexpected error message: {first_error}"
2605 );
2606
2607 let disable_retry = controller.disable();
2608 assert!(
2609 matches!(disable_retry, Err(super::DisableError::Finalize(_))),
2610 "disable should return finalization failure after prior failed drain finalization"
2611 );
2612
2613 let shutdown_retry = controller.shutdown();
2614 assert!(
2615 matches!(
2616 shutdown_retry,
2617 Err(super::ShutdownError::Finalize(
2618 super::DisableError::Finalize(_)
2619 ))
2620 ),
2621 "shutdown should return finalization failure after prior failed drain finalization"
2622 );
2623 }
2624
2625 #[test]
2626 fn drain_finalization_strict_lifecycle_failure_is_observable_and_retriable() {
2627 let output = test_output("strict-drain-failure");
2628 let controller = TailtriageController::builder("checkout-service")
2629 .output(&output)
2630 .strict_lifecycle(true)
2631 .build()
2632 .expect("build should succeed");
2633 let active = controller.enable().expect("enable should succeed");
2634
2635 let runtime = active_runtime(&controller);
2636 let leaked = runtime.run.begin_request("/leaked");
2637 let started = controller.begin_request("/checkout");
2638 assert!(matches!(
2639 controller.disable(),
2640 Ok(DisableOutcome::Closing {
2641 generation_id,
2642 inflight_captured_requests: 1
2643 }) if generation_id == active.generation_id
2644 ));
2645
2646 started.completion.finish_ok();
2647
2648 let status = controller.status();
2649 let GenerationState::Active(active_state) = status.generation else {
2650 panic!("strict lifecycle drain failure should keep generation active");
2651 };
2652 assert!(active_state.closing);
2653 assert_eq!(active_state.inflight_captured_requests, 0);
2654 let error = active_state
2655 .last_finalize_error
2656 .expect("strict lifecycle error should be reported");
2657 assert!(
2658 error.contains("strict lifecycle validation failed"),
2659 "unexpected strict lifecycle error message: {error}"
2660 );
2661
2662 assert!(matches!(
2663 controller.disable(),
2664 Err(super::DisableError::Finalize(
2665 tailtriage_core::SinkError::Lifecycle {
2666 unfinished_count: 1
2667 }
2668 ))
2669 ));
2670
2671 leaked.completion.finish_ok();
2672 assert!(matches!(
2673 controller.disable(),
2674 Ok(DisableOutcome::Finalized { generation_id }) if generation_id == active.generation_id
2675 ));
2676 fs::remove_file(active.artifact_path).expect("cleanup should succeed");
2677 }
2678
2679 #[test]
2680 fn drain_finalization_failure_allows_recovery_after_environment_fix() {
2681 let output_dir = std::env::temp_dir().join(format!(
2682 "tailtriage-controller-recovery-dir-{}-{}",
2683 std::process::id(),
2684 tailtriage_core::unix_time_ms()
2685 ));
2686 let output = output_dir.join("artifact.json");
2687 let controller = TailtriageController::builder("checkout-service")
2688 .output(&output)
2689 .build()
2690 .expect("build should succeed");
2691
2692 let active = controller.enable().expect("enable should succeed");
2693 let started = controller.begin_request("/checkout");
2694 assert!(matches!(
2695 controller.disable(),
2696 Ok(DisableOutcome::Closing {
2697 generation_id,
2698 inflight_captured_requests: 1
2699 }) if generation_id == active.generation_id
2700 ));
2701 started.completion.finish_ok();
2702
2703 let status_before_retry = controller.status();
2704 let GenerationState::Active(active_before_retry) = status_before_retry.generation else {
2705 panic!("failed drain finalization should keep generation active");
2706 };
2707 assert!(active_before_retry.last_finalize_error.is_some());
2708
2709 fs::create_dir_all(&output_dir).expect("create output directory for retry should succeed");
2710
2711 assert!(matches!(
2712 controller.disable(),
2713 Ok(DisableOutcome::Finalized { generation_id }) if generation_id == active.generation_id
2714 ));
2715 assert!(output_dir.join("artifact-generation-1.json").exists());
2716 fs::remove_file(output_dir.join("artifact-generation-1.json"))
2717 .expect("cleanup artifact should succeed");
2718 fs::remove_dir(output_dir).expect("cleanup output dir should succeed");
2719 }
2720
2721 #[test]
2722 fn toml_parsing_success_and_failure() {
2723 let output = test_output("toml-parse");
2724 let config = test_config_path("toml-parse");
2725 write_config(&config, &output, "light", false, true);
2726
2727 let loaded =
2728 TailtriageController::load_config_from_path(&config).expect("valid TOML should parse");
2729 assert_eq!(loaded.activation_template.selected_mode, CaptureMode::Light);
2730 assert_eq!(
2731 loaded.activation_template.capture_limits_override,
2732 CaptureLimitsOverride {
2733 max_requests: Some(17),
2734 max_stages: Some(18),
2735 max_queues: None,
2736 max_inflight_snapshots: None,
2737 max_runtime_snapshots: None,
2738 }
2739 );
2740 assert!(
2741 loaded
2742 .activation_template
2743 .runtime_sampler
2744 .enabled_for_armed_runs
2745 );
2746 assert_eq!(
2747 loaded.activation_template.run_end_policy,
2748 RunEndPolicy::AutoSealOnLimitsHit
2749 );
2750
2751 fs::write(&config, "[controller\n").expect("invalid TOML write should succeed");
2752 assert!(TailtriageController::load_config_from_path(&config).is_err());
2753
2754 fs::remove_file(config).expect("config cleanup should succeed");
2755 }
2756
2757 #[test]
2758 fn toml_parses_windows_style_escaped_output_path() {
2759 let config_toml = r#"[controller]
2760
2761[controller.activation]
2762mode = "light"
2763
2764[controller.activation.sink]
2765type = "local_json"
2766output_path = "C:\\Users\\someone\\AppData\\Local\\Temp\\tailtriage.json"
2767"#;
2768
2769 let parsed: super::ControllerConfigFile =
2770 toml::from_str(config_toml).expect("escaped Windows path should parse in TOML");
2771
2772 let loaded = parsed.into_loaded();
2773 assert_eq!(
2774 loaded.activation_template.sink_template,
2775 ControllerSinkTemplate::LocalJson {
2776 output_path: PathBuf::from(r"C:\Users\someone\AppData\Local\Temp\tailtriage.json"),
2777 }
2778 );
2779 }
2780
2781 #[test]
2782 fn reload_updates_next_activation_template_only() {
2783 let output_before = test_output("reload-template-before");
2784 let output_after = test_output("reload-template-after");
2785 let config = test_config_path("reload-template");
2786 write_config(&config, &output_before, "light", false, false);
2787
2788 let controller = TailtriageController::builder("checkout-service")
2789 .config_path(&config)
2790 .build()
2791 .expect("build should succeed");
2792 assert_eq!(
2793 controller.status().template.selected_mode,
2794 CaptureMode::Light
2795 );
2796
2797 write_config(&config, &output_after, "investigation", true, false);
2798 controller.reload_config().expect("reload should succeed");
2799
2800 let status = controller.status();
2801 assert_eq!(status.template.selected_mode, CaptureMode::Investigation);
2802 assert!(status.template.strict_lifecycle);
2803 assert_eq!(
2804 status.template.run_end_policy,
2805 RunEndPolicy::AutoSealOnLimitsHit
2806 );
2807
2808 fs::remove_file(config).expect("config cleanup should succeed");
2809 }
2810
2811 #[test]
2812 fn try_reload_template_validates_before_enable() {
2813 let output = test_output("try-reload-template-validate");
2814 let controller = TailtriageController::builder("checkout-service")
2815 .output(&output)
2816 .build()
2817 .expect("build should succeed");
2818
2819 let invalid = TailtriageControllerTemplate {
2820 service_name: String::new(),
2821 config_path: None,
2822 sink_template: ControllerSinkTemplate::LocalJson {
2823 output_path: output,
2824 },
2825 selected_mode: CaptureMode::Light,
2826 capture_limits_override: CaptureLimitsOverride::default(),
2827 strict_lifecycle: false,
2828 runtime_sampler: RuntimeSamplerTemplate::default(),
2829 run_end_policy: RunEndPolicy::ContinueAfterLimitsHit,
2830 };
2831
2832 assert!(matches!(
2833 controller.try_reload_template(invalid),
2834 Err(ReloadTemplateError::Validate(_))
2835 ));
2836 }
2837
2838 #[test]
2839 fn reload_config_validates_template_before_enable() {
2840 let output = test_output("reload-config-validate");
2841 let config = test_config_path("reload-config-validate");
2842 write_config(&config, &output, "light", false, false);
2843
2844 let controller = TailtriageController::builder("checkout-service")
2845 .config_path(&config)
2846 .build()
2847 .expect("build should succeed");
2848
2849 fs::write(
2850 &config,
2851 r#"[controller]
2852service_name = ""
2853
2854[controller.activation]
2855mode = "light"
2856strict_lifecycle = false
2857
2858[controller.activation.capture_limits_override]
2859max_requests = 17
2860max_stages = 18
2861
2862[controller.activation.sink]
2863type = "local_json"
2864output_path = "tailtriage-run.json"
2865
2866[controller.activation.runtime_sampler]
2867enabled_for_armed_runs = false
2868
2869[controller.activation.run_end_policy]
2870kind = "continue_after_limits_hit"
2871"#,
2872 )
2873 .expect("invalid config write should succeed");
2874
2875 assert!(matches!(
2876 controller.reload_config(),
2877 Err(ReloadConfigError::Validate(_))
2878 ));
2879
2880 fs::remove_file(config).expect("config cleanup should succeed");
2881 }
2882
2883 #[test]
2884 fn controller_recovers_after_poisoned_lifecycle_lock() {
2885 let output = test_output("poisoned-lock-recovery");
2886 let controller = TailtriageController::builder("checkout-service")
2887 .output(&output)
2888 .build()
2889 .expect("build should succeed");
2890
2891 let _ = std::panic::catch_unwind({
2892 let controller = controller.clone();
2893 move || {
2894 let _guard = controller
2895 .inner
2896 .lifecycle
2897 .lock()
2898 .unwrap_or_else(std::sync::PoisonError::into_inner);
2899 panic!("intentional poison");
2900 }
2901 });
2902
2903 let status = controller.status();
2904 assert_eq!(status.template.service_name, "checkout-service");
2905 assert!(matches!(
2906 status.generation,
2907 GenerationState::Disabled { .. }
2908 ));
2909 }
2910
2911 #[test]
2912 fn active_generation_keeps_original_config_after_reload() {
2913 let output_before = test_output("active-keeps-before");
2914 let output_after = test_output("active-keeps-after");
2915 let config = test_config_path("active-keeps");
2916 write_config(&config, &output_before, "light", false, false);
2917
2918 let controller = TailtriageController::builder("checkout-service")
2919 .config_path(&config)
2920 .build()
2921 .expect("build should succeed");
2922
2923 let gen1 = controller.enable().expect("first enable should succeed");
2924 assert_eq!(gen1.activation_config.selected_mode, CaptureMode::Light);
2925 assert_eq!(
2926 gen1.activation_config.sink_template,
2927 super::ControllerSinkTemplate::LocalJson {
2928 output_path: output_before.clone()
2929 }
2930 );
2931
2932 write_config(&config, &output_after, "investigation", true, false);
2933 controller.reload_config().expect("reload should succeed");
2934
2935 let GenerationState::Active(active_after_reload) = controller.status().generation else {
2936 panic!("expected active generation");
2937 };
2938 assert_eq!(
2939 active_after_reload.activation_config.selected_mode,
2940 CaptureMode::Light
2941 );
2942 assert!(!active_after_reload.activation_config.strict_lifecycle);
2943
2944 let started = controller.begin_request("/checkout");
2945 started.completion.finish_ok();
2946 assert!(matches!(
2947 controller.disable(),
2948 Ok(DisableOutcome::Finalized { generation_id }) if generation_id == gen1.generation_id
2949 ));
2950
2951 let gen2 = controller.enable().expect("second enable should succeed");
2952 assert_eq!(
2953 gen2.activation_config.selected_mode,
2954 CaptureMode::Investigation
2955 );
2956 assert!(gen2.activation_config.strict_lifecycle);
2957 assert_eq!(
2958 gen2.activation_config.sink_template,
2959 super::ControllerSinkTemplate::LocalJson {
2960 output_path: output_after.clone()
2961 }
2962 );
2963
2964 assert!(matches!(
2965 controller.disable(),
2966 Ok(DisableOutcome::Finalized { generation_id }) if generation_id == gen2.generation_id
2967 ));
2968
2969 fs::remove_file(gen1.artifact_path).expect("cleanup gen1 should succeed");
2970 fs::remove_file(gen2.artifact_path).expect("cleanup gen2 should succeed");
2971 fs::remove_file(config).expect("config cleanup should succeed");
2972 }
2973
2974 #[test]
2975 fn build_from_toml_initially_enabled_starts_generation_with_toml_activation_settings() {
2976 let output = test_output("toml-initially-enabled");
2977 let config = test_config_path("toml-initially-enabled");
2978 write_initially_enabled_config(&config, &output);
2979
2980 let controller = TailtriageController::builder("builder-service-name")
2981 .initially_enabled(false)
2982 .strict_lifecycle(false)
2983 .config_path(&config)
2984 .build()
2985 .expect("build should succeed");
2986
2987 let status = controller.status();
2988 let GenerationState::Active(active) = status.generation else {
2989 panic!("config with initially_enabled=true should start generation 1");
2990 };
2991 assert_eq!(active.generation_id, 1);
2992 assert_eq!(
2993 active.activation_config.selected_mode,
2994 CaptureMode::Investigation
2995 );
2996 assert!(active.activation_config.strict_lifecycle);
2997 assert_eq!(
2998 active.activation_config.run_end_policy,
2999 RunEndPolicy::AutoSealOnLimitsHit
3000 );
3001 assert_eq!(
3002 active.activation_config.sink_template,
3003 ControllerSinkTemplate::LocalJson {
3004 output_path: output.clone()
3005 }
3006 );
3007 assert_eq!(
3008 active.activation_config.runtime_sampler,
3009 RuntimeSamplerTemplate::default()
3010 );
3011 assert_eq!(
3012 active.activation_config.capture_limits_override,
3013 CaptureLimitsOverride {
3014 max_requests: Some(9),
3015 ..CaptureLimitsOverride::default()
3016 }
3017 );
3018 assert_eq!(status.template.service_name, "toml-service-name");
3019 assert_eq!(
3020 active.artifact_path,
3021 output.with_file_name(format!(
3022 "{}-generation-1.json",
3023 output
3024 .file_stem()
3025 .and_then(std::ffi::OsStr::to_str)
3026 .expect("stem")
3027 ))
3028 );
3029
3030 assert!(matches!(
3031 controller.disable(),
3032 Ok(DisableOutcome::Finalized { generation_id: 1 })
3033 ));
3034 let run = read_run(&active.artifact_path);
3035 assert_eq!(
3036 run.metadata.run_end_reason,
3037 Some(tailtriage_core::RunEndReason::ManualDisarm)
3038 );
3039
3040 fs::remove_file(active.artifact_path).expect("artifact cleanup should succeed");
3041 fs::remove_file(config).expect("config cleanup should succeed");
3042 }
3043
3044 #[test]
3045 fn enable_with_sampler_without_tokio_runtime_returns_missing_runtime_error() {
3046 let output = test_output("missing-runtime");
3047 let expected_artifact = output.with_file_name(format!(
3048 "{}-generation-1.json",
3049 output
3050 .file_stem()
3051 .and_then(std::ffi::OsStr::to_str)
3052 .expect("stem")
3053 ));
3054 let controller = TailtriageController::builder("checkout-service")
3055 .output(&output)
3056 .runtime_sampler(RuntimeSamplerTemplate {
3057 enabled_for_armed_runs: true,
3058 mode_override: None,
3059 interval_ms: Some(20),
3060 max_runtime_snapshots: Some(10),
3061 })
3062 .build()
3063 .expect("build should succeed");
3064
3065 let err = controller
3066 .enable()
3067 .expect_err("enable should fail without runtime");
3068 assert!(matches!(err, EnableError::MissingTokioRuntimeForSampler));
3069 assert!(matches!(
3070 controller.status().generation,
3071 GenerationState::Disabled { next_generation: 1 }
3072 ));
3073 assert!(!expected_artifact.exists());
3074 }
3075
3076 #[test]
3077 fn sparse_toml_uses_builder_fallbacks_and_activation_defaults() {
3078 let output = test_output("sparse-toml-defaults");
3079 let config = test_config_path("sparse-toml-defaults");
3080 write_sparse_config(&config, &output, "investigation");
3081
3082 let controller = TailtriageController::builder("builder-service-name")
3083 .initially_enabled(true)
3084 .config_path(&config)
3085 .build()
3086 .expect("build should succeed");
3087
3088 let status = controller.status();
3089 assert_eq!(status.template.service_name, "builder-service-name");
3090 let GenerationState::Active(active) = status.generation else {
3091 panic!("builder initially_enabled should be preserved when TOML omits it");
3092 };
3093 assert_eq!(active.generation_id, 1);
3094 assert_eq!(
3095 active.activation_config.selected_mode,
3096 CaptureMode::Investigation
3097 );
3098 assert!(!active.activation_config.strict_lifecycle);
3099 assert_eq!(
3100 active.activation_config.runtime_sampler,
3101 RuntimeSamplerTemplate::default()
3102 );
3103 assert_eq!(
3104 active.activation_config.run_end_policy,
3105 RunEndPolicy::ContinueAfterLimitsHit
3106 );
3107 assert_eq!(
3108 active.activation_config.capture_limits_override,
3109 CaptureLimitsOverride::default()
3110 );
3111 assert_eq!(
3112 active.activation_config.sink_template,
3113 ControllerSinkTemplate::LocalJson {
3114 output_path: output.clone()
3115 }
3116 );
3117
3118 assert!(matches!(
3119 controller.disable(),
3120 Ok(DisableOutcome::Finalized { generation_id: 1 })
3121 ));
3122 fs::remove_file(active.artifact_path).expect("artifact cleanup should succeed");
3123 fs::remove_file(config).expect("config cleanup should succeed");
3124 }
3125
3126 #[test]
3127 fn build_with_missing_config_path_returns_config_load_error() {
3128 let config = test_config_path("missing-config-build");
3129 assert!(!config.exists());
3130
3131 let err = TailtriageController::builder("checkout-service")
3132 .config_path(&config)
3133 .build()
3134 .expect_err("build should fail for missing config path");
3135 assert!(matches!(
3136 err,
3137 ControllerBuildError::ConfigLoad(super::ConfigLoadError::Io { .. })
3138 ));
3139 }
3140
3141 #[test]
3142 fn config_service_name_overrides_builder_service_name_when_present() {
3143 let output = test_output("build-config-service-name-overrides");
3144 let config = test_config_path("build-config-service-name-overrides");
3145 write_config_with_optional_service_name(&config, &output, Some("toml-service-name"));
3146
3147 let controller = TailtriageController::builder("builder-service-name")
3148 .config_path(&config)
3149 .build()
3150 .expect("build should succeed");
3151 assert_eq!(
3152 controller.status().template.service_name,
3153 "toml-service-name"
3154 );
3155
3156 fs::remove_file(config).expect("config cleanup should succeed");
3157 }
3158
3159 #[test]
3160 fn blank_builder_service_name_uses_non_blank_toml_service_name() {
3161 let output = test_output("build-blank-builder-uses-toml");
3162 let config = test_config_path("build-blank-builder-uses-toml");
3163 write_config_with_optional_service_name(&config, &output, Some("toml-service-name"));
3164
3165 let controller = TailtriageController::builder(" ")
3166 .config_path(&config)
3167 .build()
3168 .expect("build should succeed");
3169 assert_eq!(
3170 controller.status().template.service_name,
3171 "toml-service-name"
3172 );
3173
3174 fs::remove_file(config).expect("config cleanup should succeed");
3175 }
3176
3177 #[test]
3178 fn blank_builder_service_name_without_config_fails_build() {
3179 let err = TailtriageController::builder(" ")
3180 .build()
3181 .expect_err("blank builder service_name without config should fail");
3182 assert!(matches!(err, ControllerBuildError::EmptyServiceName));
3183 }
3184
3185 #[test]
3186 fn blank_builder_and_blank_toml_service_name_fail_build() {
3187 let output = test_output("build-blank-builder-blank-toml");
3188 let config = test_config_path("build-blank-builder-blank-toml");
3189 write_config_with_optional_service_name(&config, &output, Some(""));
3190
3191 let err = TailtriageController::builder(" ")
3192 .config_path(&config)
3193 .build()
3194 .expect_err("blank builder and blank TOML service_name should fail");
3195 assert!(matches!(err, ControllerBuildError::EmptyServiceName));
3196
3197 fs::remove_file(config).expect("config cleanup should succeed");
3198 }
3199
3200 #[test]
3201 fn build_from_toml_with_blank_service_name_returns_empty_service_name_error() {
3202 let config = test_config_path("toml-empty-service-name");
3203 write_raw_config(
3204 &config,
3205 r#"[controller]
3206service_name = ""
3207
3208[controller.activation]
3209mode = "light"
3210
3211[controller.activation.sink]
3212type = "local_json"
3213output_path = "tailtriage-run.json"
3214"#,
3215 );
3216
3217 let err = TailtriageController::builder("fallback-service-name")
3218 .config_path(&config)
3219 .build()
3220 .expect_err("blank TOML service_name should fail build");
3221 assert!(matches!(err, ControllerBuildError::EmptyServiceName));
3222
3223 fs::remove_file(config).expect("config cleanup should succeed");
3224 }
3225
3226 #[test]
3227 fn build_from_toml_with_invalid_mode_returns_parse_error() {
3228 let config = test_config_path("toml-invalid-mode");
3229 write_raw_config(
3230 &config,
3231 r#"[controller]
3232
3233[controller.activation]
3234mode = "not-a-real-mode"
3235
3236[controller.activation.sink]
3237type = "local_json"
3238output_path = "tailtriage-run.json"
3239"#,
3240 );
3241
3242 let err = TailtriageController::builder("checkout-service")
3243 .config_path(&config)
3244 .build()
3245 .expect_err("invalid mode should fail build");
3246 assert!(matches!(
3247 err,
3248 ControllerBuildError::ConfigLoad(super::ConfigLoadError::Parse { .. })
3249 ));
3250
3251 fs::remove_file(config).expect("config cleanup should succeed");
3252 }
3253
3254 #[test]
3255 fn build_from_toml_with_invalid_run_end_policy_kind_returns_parse_error() {
3256 let config = test_config_path("toml-invalid-run-end-policy");
3257 write_raw_config(
3258 &config,
3259 r#"[controller]
3260
3261[controller.activation]
3262mode = "light"
3263
3264[controller.activation.sink]
3265type = "local_json"
3266output_path = "tailtriage-run.json"
3267
3268[controller.activation.run_end_policy]
3269kind = "not-a-real-policy"
3270"#,
3271 );
3272
3273 let err = TailtriageController::builder("checkout-service")
3274 .config_path(&config)
3275 .build()
3276 .expect_err("invalid run_end_policy.kind should fail build");
3277 assert!(matches!(
3278 err,
3279 ControllerBuildError::ConfigLoad(super::ConfigLoadError::Parse { .. })
3280 ));
3281
3282 fs::remove_file(config).expect("config cleanup should succeed");
3283 }
3284
3285 #[test]
3286 fn build_from_toml_with_run_end_policy_table_missing_kind_returns_parse_error() {
3287 let config = test_config_path("toml-run-end-policy-missing-kind");
3288 write_raw_config(
3289 &config,
3290 r#"[controller]
3291
3292[controller.activation]
3293mode = "light"
3294
3295[controller.activation.sink]
3296type = "local_json"
3297output_path = "tailtriage-run.json"
3298
3299[controller.activation.run_end_policy]
3300"#,
3301 );
3302
3303 let err = TailtriageController::builder("checkout-service")
3304 .config_path(&config)
3305 .build()
3306 .expect_err("run_end_policy table without kind should fail build");
3307 assert!(matches!(
3308 err,
3309 ControllerBuildError::ConfigLoad(super::ConfigLoadError::Parse { .. })
3310 ));
3311
3312 fs::remove_file(config).expect("config cleanup should succeed");
3313 }
3314
3315 #[test]
3316 fn build_from_toml_with_invalid_sink_type_returns_parse_error() {
3317 let config = test_config_path("toml-invalid-sink-type");
3318 write_raw_config(
3319 &config,
3320 r#"[controller]
3321
3322[controller.activation]
3323mode = "light"
3324
3325[controller.activation.sink]
3326type = "not-a-real-sink"
3327output_path = "tailtriage-run.json"
3328"#,
3329 );
3330
3331 let err = TailtriageController::builder("checkout-service")
3332 .config_path(&config)
3333 .build()
3334 .expect_err("invalid sink.type should fail build");
3335 assert!(matches!(
3336 err,
3337 ControllerBuildError::ConfigLoad(super::ConfigLoadError::Parse { .. })
3338 ));
3339
3340 fs::remove_file(config).expect("config cleanup should succeed");
3341 }
3342
3343 #[test]
3344 fn build_from_toml_initially_enabled_sampler_without_runtime_returns_initial_enable_error() {
3345 let config = test_config_path("toml-initially-enabled-missing-runtime");
3346 write_raw_config(
3347 &config,
3348 r#"[controller]
3349initially_enabled = true
3350
3351[controller.activation]
3352mode = "light"
3353
3354[controller.activation.sink]
3355type = "local_json"
3356output_path = "tailtriage-run.json"
3357
3358[controller.activation.runtime_sampler]
3359enabled_for_armed_runs = true
3360interval_ms = 20
3361max_runtime_snapshots = 10
3362"#,
3363 );
3364
3365 let err = TailtriageController::builder("checkout-service")
3366 .config_path(&config)
3367 .build()
3368 .expect_err("initially_enabled with sampler should fail outside Tokio runtime");
3369 assert!(matches!(
3370 err,
3371 ControllerBuildError::InitialEnable(EnableError::MissingTokioRuntimeForSampler)
3372 ));
3373
3374 fs::remove_file(config).expect("config cleanup should succeed");
3375 }
3376
3377 #[test]
3378 fn reload_config_after_config_file_deleted_returns_load_error() {
3379 let output = test_output("reload-deleted-config");
3380 let config = test_config_path("reload-deleted-config");
3381 write_config(&config, &output, "light", false, false);
3382
3383 let controller = TailtriageController::builder("checkout-service")
3384 .config_path(&config)
3385 .build()
3386 .expect("build should succeed");
3387
3388 fs::remove_file(&config).expect("config delete should succeed");
3389 assert!(matches!(
3390 controller.reload_config(),
3391 Err(ReloadConfigError::Load(super::ConfigLoadError::Io { .. }))
3392 ));
3393 }
3394
3395 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3396 async fn armed_generation_with_sampler_enabled_records_effective_metadata() {
3397 let output = test_output("sampler-enabled");
3398 let controller = TailtriageController::builder("checkout-service")
3399 .output(&output)
3400 .runtime_sampler(RuntimeSamplerTemplate {
3401 enabled_for_armed_runs: true,
3402 mode_override: Some(CaptureMode::Investigation),
3403 interval_ms: Some(15),
3404 max_runtime_snapshots: Some(8),
3405 })
3406 .capture_limits_override(CaptureLimitsOverride {
3407 max_runtime_snapshots: Some(3),
3408 ..CaptureLimitsOverride::default()
3409 })
3410 .build()
3411 .expect("build should succeed");
3412
3413 let active = controller.enable().expect("enable should succeed");
3414 tokio::time::sleep(Duration::from_millis(40)).await;
3415 assert!(matches!(
3416 controller.disable(),
3417 Ok(DisableOutcome::Finalized { generation_id }) if generation_id == active.generation_id
3418 ));
3419
3420 let run = read_run(&active.artifact_path);
3421 let config = run
3422 .metadata
3423 .effective_tokio_sampler_config
3424 .expect("sampler metadata should be set");
3425 assert_eq!(config.inherited_mode, CaptureMode::Light);
3426 assert_eq!(
3427 config.explicit_mode_override,
3428 Some(CaptureMode::Investigation)
3429 );
3430 assert_eq!(config.resolved_mode, CaptureMode::Investigation);
3431 assert_eq!(config.resolved_sampler_cadence_ms, 15);
3432 assert_eq!(config.resolved_runtime_snapshot_retention, 3);
3433
3434 fs::remove_file(active.artifact_path).expect("cleanup should succeed");
3435 }
3436
3437 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3438 async fn armed_generation_with_sampler_disabled_keeps_sampler_metadata_empty() {
3439 let output = test_output("sampler-disabled");
3440 let controller = TailtriageController::builder("checkout-service")
3441 .output(&output)
3442 .runtime_sampler(RuntimeSamplerTemplate {
3443 enabled_for_armed_runs: false,
3444 mode_override: Some(CaptureMode::Investigation),
3445 interval_ms: Some(5),
3446 max_runtime_snapshots: Some(100),
3447 })
3448 .build()
3449 .expect("build should succeed");
3450
3451 let active = controller.enable().expect("enable should succeed");
3452 tokio::time::sleep(Duration::from_millis(20)).await;
3453 assert!(matches!(
3454 controller.disable(),
3455 Ok(DisableOutcome::Finalized { generation_id }) if generation_id == active.generation_id
3456 ));
3457
3458 let run = read_run(&active.artifact_path);
3459 assert!(run.metadata.effective_tokio_sampler_config.is_none());
3460 assert!(run.runtime_snapshots.is_empty());
3461
3462 fs::remove_file(active.artifact_path).expect("cleanup should succeed");
3463 }
3464
3465 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3466 async fn sampler_stops_on_disarm_and_reenable_uses_fresh_generation_sampler_lifecycle() {
3467 let output = test_output("sampler-reenable");
3468 let controller = TailtriageController::builder("checkout-service")
3469 .output(&output)
3470 .runtime_sampler(RuntimeSamplerTemplate {
3471 enabled_for_armed_runs: true,
3472 mode_override: None,
3473 interval_ms: Some(10),
3474 max_runtime_snapshots: Some(32),
3475 })
3476 .build()
3477 .expect("build should succeed");
3478
3479 let first = controller.enable().expect("first enable should succeed");
3480 tokio::time::sleep(Duration::from_millis(35)).await;
3481 assert!(matches!(
3482 controller.disable(),
3483 Ok(DisableOutcome::Finalized { generation_id }) if generation_id == first.generation_id
3484 ));
3485 tokio::time::sleep(Duration::from_millis(30)).await;
3486
3487 let first_run = read_run(&first.artifact_path);
3488 assert!(!first_run.runtime_snapshots.is_empty());
3489 let first_metadata = first_run
3490 .metadata
3491 .effective_tokio_sampler_config
3492 .expect("first generation sampler metadata should exist");
3493
3494 let second = controller.enable().expect("second enable should succeed");
3495 assert_eq!(second.generation_id, first.generation_id + 1);
3496 tokio::time::sleep(Duration::from_millis(35)).await;
3497 assert!(matches!(
3498 controller.disable(),
3499 Ok(DisableOutcome::Finalized { generation_id }) if generation_id == second.generation_id
3500 ));
3501
3502 let second_run = read_run(&second.artifact_path);
3503 assert!(!second_run.runtime_snapshots.is_empty());
3504 let second_metadata = second_run
3505 .metadata
3506 .effective_tokio_sampler_config
3507 .expect("second generation sampler metadata should exist");
3508
3509 assert_eq!(first_metadata.resolved_sampler_cadence_ms, 10);
3510 assert_eq!(second_metadata.resolved_sampler_cadence_ms, 10);
3511 assert_ne!(first.artifact_path, second.artifact_path);
3512
3513 fs::remove_file(first.artifact_path).expect("cleanup first should succeed");
3514 fs::remove_file(second.artifact_path).expect("cleanup second should succeed");
3515 }
3516}