nextest_runner/record/
session.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Recording session management.
5//!
6//! This module provides [`RecordSession`], which encapsulates the full lifecycle of
7//! a recording session: setup, integration with the reporter, and finalization.
8//! This allows both `run` and `bench` commands to share recording logic.
9
10use super::{
11    CompletedRunStats, RecordedRunStatus, RunRecorder, RunStore, ShortestRunIdPrefix, StoreSizes,
12    StressCompletedRunStats, records_cache_dir,
13    retention::{PruneResult, RecordRetentionPolicy},
14};
15use crate::{
16    errors::{RecordPruneError, RecordSetupError, RunStoreError},
17    record::{Styles, format::RerunInfo},
18    reporter::{
19        RunFinishedInfo,
20        events::{FinalRunStats, RunFinishedStats, StressFinalRunStats},
21    },
22};
23use bytesize::ByteSize;
24use camino::{Utf8Path, Utf8PathBuf};
25use chrono::{DateTime, FixedOffset};
26use owo_colors::OwoColorize;
27use quick_junit::ReportUuid;
28use semver::Version;
29use std::{collections::BTreeMap, fmt};
30
31/// Configuration for creating a recording session.
32#[derive(Clone, Debug)]
33pub struct RecordSessionConfig<'a> {
34    /// The workspace root path, used to determine the cache directory.
35    pub workspace_root: &'a Utf8Path,
36    /// The unique identifier for this run.
37    pub run_id: ReportUuid,
38    /// The version of nextest creating this recording.
39    pub nextest_version: Version,
40    /// When the run started.
41    pub started_at: DateTime<FixedOffset>,
42    /// The command-line arguments used to invoke nextest.
43    pub cli_args: Vec<String>,
44    /// Build scope arguments (package and target selection).
45    ///
46    /// These determine which packages and targets are built. In a rerun chain,
47    /// these are inherited from the original run unless explicitly overridden.
48    pub build_scope_args: Vec<String>,
49    /// Environment variables that affect nextest behavior (NEXTEST_* and CARGO_*).
50    pub env_vars: BTreeMap<String, String>,
51    /// Maximum size per output file before truncation.
52    pub max_output_size: ByteSize,
53    /// Rerun-specific metadata, if this is a rerun.
54    ///
55    /// If present, this will be written to `meta/rerun-info.json` in the archive.
56    pub rerun_info: Option<RerunInfo>,
57}
58
59/// Result of setting up a recording session.
60#[derive(Debug)]
61pub struct RecordSessionSetup {
62    /// The session handle for later finalization.
63    pub session: RecordSession,
64    /// The recorder to pass to the structured reporter.
65    pub recorder: RunRecorder,
66    /// The shortest unique prefix for the run ID.
67    ///
68    /// This can be used for display purposes to highlight the unique prefix
69    /// portion of the run ID.
70    pub run_id_unique_prefix: ShortestRunIdPrefix,
71}
72
73/// Manages the full lifecycle of a recording session.
74///
75/// This type encapsulates setup, execution integration, and finalization.
76#[derive(Debug)]
77pub struct RecordSession {
78    cache_dir: Utf8PathBuf,
79    run_id: ReportUuid,
80}
81
82impl RecordSession {
83    /// Sets up a new recording session.
84    ///
85    /// Creates the run store, acquires an exclusive lock, and creates the
86    /// recorder. The lock is released after setup completes (the recorder
87    /// writes independently).
88    ///
89    /// Returns a setup result containing the session handle and recorder, or an
90    /// error if setup fails.
91    pub fn setup(config: RecordSessionConfig<'_>) -> Result<RecordSessionSetup, RecordSetupError> {
92        let cache_dir =
93            records_cache_dir(config.workspace_root).map_err(RecordSetupError::CacheDirNotFound)?;
94
95        let store = RunStore::new(&cache_dir).map_err(RecordSetupError::StoreCreate)?;
96
97        let locked_store = store
98            .lock_exclusive()
99            .map_err(RecordSetupError::StoreLock)?;
100
101        let (mut recorder, run_id_unique_prefix) = locked_store
102            .create_run_recorder(
103                config.run_id,
104                config.nextest_version,
105                config.started_at,
106                config.cli_args,
107                config.build_scope_args,
108                config.env_vars,
109                config.max_output_size,
110                config.rerun_info.as_ref().map(|info| info.parent_run_id),
111            )
112            .map_err(RecordSetupError::RecorderCreate)?;
113
114        // If this is a rerun, write the rerun info to the archive.
115        if let Some(rerun_info) = config.rerun_info {
116            recorder
117                .write_rerun_info(&rerun_info)
118                .map_err(RecordSetupError::RecorderCreate)?;
119        }
120
121        let session = RecordSession {
122            cache_dir,
123            run_id: config.run_id,
124        };
125
126        Ok(RecordSessionSetup {
127            session,
128            recorder,
129            run_id_unique_prefix,
130        })
131    }
132
133    /// Returns the run ID for this session.
134    pub fn run_id(&self) -> ReportUuid {
135        self.run_id
136    }
137
138    /// Returns the cache directory for this session.
139    pub fn cache_dir(&self) -> &Utf8Path {
140        &self.cache_dir
141    }
142
143    /// Finalizes the recording session after the run completes.
144    ///
145    /// This method marks the run as completed with its final sizes and stats.
146    ///
147    /// All errors during finalization are non-fatal and returned as warnings,
148    /// since the recording itself has already completed successfully.
149    ///
150    /// This should be called after `reporter.finish()` returns the recording sizes.
151    ///
152    /// The `exit_code` parameter should be the exit code that the process will
153    /// return. This is stored in the run metadata for later inspection.
154    pub fn finalize(
155        self,
156        recording_sizes: Option<StoreSizes>,
157        run_finished: Option<RunFinishedInfo>,
158        exit_code: i32,
159        policy: &RecordRetentionPolicy,
160    ) -> RecordFinalizeResult {
161        let mut result = RecordFinalizeResult::default();
162
163        // If recording didn't produce sizes, there's nothing to finalize.
164        let Some(sizes) = recording_sizes else {
165            return result;
166        };
167
168        // Convert run finished info to status and duration.
169        let (status, duration_secs) = match run_finished {
170            Some(info) => (
171                convert_run_stats_to_status(info.stats, exit_code),
172                Some(info.elapsed.as_secs_f64()),
173            ),
174            // This shouldn't happen when recording_sizes is Some, but handle gracefully.
175            None => (RecordedRunStatus::Incomplete, None),
176        };
177
178        // Re-open the store and acquire the lock.
179        let store = match RunStore::new(&self.cache_dir) {
180            Ok(store) => store,
181            Err(err) => {
182                result
183                    .warnings
184                    .push(RecordFinalizeWarning::StoreOpenFailed(err));
185                return result;
186            }
187        };
188
189        let mut locked_store = match store.lock_exclusive() {
190            Ok(locked) => locked,
191            Err(err) => {
192                result
193                    .warnings
194                    .push(RecordFinalizeWarning::StoreLockFailed(err));
195                return result;
196            }
197        };
198
199        // Mark the run as completed and persist.
200        match locked_store.complete_run(self.run_id, sizes, status, duration_secs) {
201            Ok(true) => {}
202            Ok(false) => {
203                // Run was not found in the store, likely pruned during execution.
204                result
205                    .warnings
206                    .push(RecordFinalizeWarning::RunNotFoundDuringComplete(
207                        self.run_id,
208                    ));
209            }
210            Err(err) => {
211                result
212                    .warnings
213                    .push(RecordFinalizeWarning::MetadataPersistFailed(err));
214            }
215        }
216        // Continue with pruning even if metadata persistence failed.
217
218        // Prune old runs if needed (once daily or if limits exceeded by 1.5x).
219        match locked_store.prune_if_needed(policy) {
220            Ok(Some(mut prune_result)) => {
221                // Move any errors that occurred during pruning into warnings.
222                for error in prune_result.errors.drain(..) {
223                    result
224                        .warnings
225                        .push(RecordFinalizeWarning::PruneError(error));
226                }
227                result.prune_result = Some(prune_result);
228            }
229            Ok(None) => {
230                // Pruning was skipped; nothing to do.
231            }
232            Err(err) => {
233                result
234                    .warnings
235                    .push(RecordFinalizeWarning::PruneFailed(err));
236            }
237        }
238
239        result
240    }
241}
242
243/// Converts `RunFinishedStats` to `RecordedRunStatus`.
244fn convert_run_stats_to_status(stats: RunFinishedStats, exit_code: i32) -> RecordedRunStatus {
245    match stats {
246        RunFinishedStats::Single(run_stats) => {
247            let completed_stats = CompletedRunStats {
248                initial_run_count: run_stats.initial_run_count,
249                passed: run_stats.passed,
250                failed: run_stats.failed_count(),
251                exit_code,
252            };
253
254            // Check if the run was cancelled based on final stats.
255            match run_stats.summarize_final() {
256                FinalRunStats::Success
257                | FinalRunStats::NoTestsRun
258                | FinalRunStats::Failed { .. } => RecordedRunStatus::Completed(completed_stats),
259                FinalRunStats::Cancelled { .. } => RecordedRunStatus::Cancelled(completed_stats),
260            }
261        }
262        RunFinishedStats::Stress(stress_stats) => {
263            let stress_completed_stats = StressCompletedRunStats {
264                initial_iteration_count: stress_stats.completed.total,
265                success_count: stress_stats.success_count,
266                failed_count: stress_stats.failed_count,
267                exit_code,
268            };
269
270            // Check if the stress run was cancelled.
271            match stress_stats.summarize_final() {
272                StressFinalRunStats::Success
273                | StressFinalRunStats::NoTestsRun
274                | StressFinalRunStats::Failed => {
275                    RecordedRunStatus::StressCompleted(stress_completed_stats)
276                }
277                StressFinalRunStats::Cancelled => {
278                    RecordedRunStatus::StressCancelled(stress_completed_stats)
279                }
280            }
281        }
282    }
283}
284
285/// Result of finalizing a recording session.
286#[derive(Debug, Default)]
287pub struct RecordFinalizeResult {
288    /// Warnings encountered during finalization.
289    pub warnings: Vec<RecordFinalizeWarning>,
290    /// The prune result, if pruning was performed.
291    pub prune_result: Option<PruneResult>,
292}
293
294impl RecordFinalizeResult {
295    /// Logs warnings and pruning statistics from the finalization result.
296    pub fn log(&self, styles: &Styles) {
297        for warning in &self.warnings {
298            tracing::warn!("{warning}");
299        }
300
301        if let Some(prune_result) = &self.prune_result
302            && (prune_result.deleted_count > 0 || prune_result.orphans_deleted > 0)
303        {
304            tracing::info!(
305                "{}(hint: {} to replay runs)",
306                prune_result.display(styles),
307                "cargo nextest replay".style(styles.count),
308            );
309        }
310    }
311}
312
313/// Non-fatal warning during recording finalization.
314#[derive(Debug)]
315pub enum RecordFinalizeWarning {
316    /// Recording completed but the run store couldn't be opened.
317    StoreOpenFailed(RunStoreError),
318    /// Recording completed but the run store couldn't be locked.
319    StoreLockFailed(RunStoreError),
320    /// Recording completed but run metadata couldn't be persisted.
321    MetadataPersistFailed(RunStoreError),
322    /// Recording completed but the run was not found in the store.
323    ///
324    /// This can happen if an aggressive prune deleted the run while the test
325    /// was still executing.
326    RunNotFoundDuringComplete(ReportUuid),
327    /// Error during pruning (overall prune operation failed).
328    PruneFailed(RunStoreError),
329    /// Error during pruning (individual run or orphan deletion failed).
330    PruneError(RecordPruneError),
331}
332
333impl fmt::Display for RecordFinalizeWarning {
334    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
335        match self {
336            Self::StoreOpenFailed(err) => {
337                write!(f, "recording completed but failed to open run store: {err}")
338            }
339            Self::StoreLockFailed(err) => {
340                write!(f, "recording completed but failed to lock run store: {err}")
341            }
342            Self::MetadataPersistFailed(err) => {
343                write!(
344                    f,
345                    "recording completed but failed to persist run metadata: {err}"
346                )
347            }
348            Self::RunNotFoundDuringComplete(run_id) => {
349                write!(
350                    f,
351                    "recording completed but run {run_id} was not found in store \
352                     (may have been pruned during execution)"
353                )
354            }
355            Self::PruneFailed(err) => write!(f, "error during prune: {err}"),
356            Self::PruneError(msg) => write!(f, "error during prune: {msg}"),
357        }
358    }
359}