Skip to main content

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