Skip to main content

candle_graph/
publication.rs

1//! Canonical capture-to-bundle publication with idempotent crash reconciliation.
2//!
3//! [`CaptureRun`] owns the whole path from "start capturing one planned profile
4//! run" to "a deeply verified, atomically published evidence bundle exists at
5//! the destination": staging-trace placement, session lifetime, evidence and
6//! viewer derivation, manifest hashing, fsync, atomic rename, and a typed
7//! [`PublicationReceipt`]. Applications never write evidence files or rename
8//! directories themselves.
9//!
10//! Publication is idempotent across crashes: retrying the same planned capture
11//! against a destination that already holds a verified bundle for the same run
12//! coordinates returns [`PublicationStatus::AlreadyPublished`] with a fresh
13//! verification receipt, while any other pre-existing destination fails closed
14//! as a conflict.
15
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use anyhow::{bail, ensure, Context, Result};
21use serde::{Deserialize, Serialize};
22
23use crate::artifact::{publish_bundle, verify_bundle, BundleVerificationReceipt};
24use crate::instrument::{ProfileRun, TraceSession};
25use crate::trace::{parse_trace, TraceRunMeta};
26
27pub const PUBLICATION_SCHEMA: &str = "candle-graph/publication/1";
28
29/// Whether this receipt covers a fresh publication or a reconciled retry.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum PublicationStatus {
33    Published,
34    AlreadyPublished,
35}
36
37/// Typed proof that one planned capture is durably published and verified.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct PublicationReceipt {
40    pub schema: String,
41    pub status: PublicationStatus,
42    pub bundle_path: PathBuf,
43    pub run_id: String,
44    pub verification: BundleVerificationReceipt,
45}
46
47/// Outcome of [`CaptureRun::begin`]: either the planned capture is already
48/// durably published (crash-retry reconciliation) or an active capture run.
49#[allow(clippy::large_enum_variant)]
50pub enum CaptureBegin {
51    AlreadyPublished(Box<PublicationReceipt>),
52    Active(CaptureRun),
53}
54
55impl std::fmt::Debug for CaptureBegin {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        match self {
58            Self::AlreadyPublished(receipt) => {
59                f.debug_tuple("AlreadyPublished").field(receipt).finish()
60            }
61            Self::Active(run) => f
62                .debug_struct("Active")
63                .field("destination", &run.destination)
64                .field("staging_trace", &run.staging_trace)
65                .finish(),
66        }
67    }
68}
69
70/// One planned capture from session open through atomic bundle publication.
71pub struct CaptureRun {
72    session: Option<TraceSession>,
73    run: ProfileRun,
74    destination: PathBuf,
75    staging_trace: PathBuf,
76    nsight_dir: Option<PathBuf>,
77}
78
79impl CaptureRun {
80    /// Reconcile the destination and, when the planned capture is not yet
81    /// published, open a staged trace session next to it.
82    pub fn begin(destination: impl Into<PathBuf>, run: ProfileRun) -> Result<CaptureBegin> {
83        let destination = destination.into();
84        for ancestor in destination.ancestors().skip(1) {
85            ensure!(
86                !ancestor.join("bundle.json").is_file(),
87                "refusing to publish bundle {} inside existing bundle {}",
88                destination.display(),
89                ancestor.display()
90            );
91        }
92        if let Some(receipt) = reconcile_published_bundle(&destination, &run)? {
93            return Ok(CaptureBegin::AlreadyPublished(Box::new(receipt)));
94        }
95        let parent = destination
96            .parent()
97            .filter(|parent| !parent.as_os_str().is_empty())
98            .unwrap_or_else(|| Path::new("."));
99        fs::create_dir_all(parent)
100            .with_context(|| format!("create bundle parent {}", parent.display()))?;
101        let name = destination
102            .file_name()
103            .and_then(|name| name.to_str())
104            .context("bundle destination needs a file name")?;
105        let nonce = SystemTime::now()
106            .duration_since(UNIX_EPOCH)
107            .unwrap_or_default()
108            .as_nanos();
109        let staging_trace = parent.join(format!(
110            ".{name}.trace-{}-{nonce}.jsonl",
111            std::process::id()
112        ));
113        let session = TraceSession::open(&staging_trace, run.clone())?;
114        Ok(CaptureBegin::Active(Self {
115            session: Some(session),
116            run,
117            destination,
118            staging_trace,
119            nsight_dir: None,
120        }))
121    }
122
123    /// The live trace session for spans, ops, stats, scalars, and gradients.
124    pub fn session(&self) -> &TraceSession {
125        self.session
126            .as_ref()
127            .expect("an active capture run owns its trace session")
128    }
129
130    /// Retain a directory of official Nsight artifacts inside the bundle.
131    pub fn with_nsight_dir(mut self, nsight_dir: impl Into<PathBuf>) -> Self {
132        self.nsight_dir = Some(nsight_dir.into());
133        self
134    }
135
136    pub fn destination(&self) -> &Path {
137        &self.destination
138    }
139
140    /// The staged trace path. It exists until publication succeeds; on failure
141    /// it is retained for diagnosis.
142    pub fn staging_trace(&self) -> &Path {
143        &self.staging_trace
144    }
145
146    /// Finish the session as complete and atomically publish the bundle.
147    pub fn publish(mut self) -> Result<PublicationReceipt> {
148        let session = self
149            .session
150            .take()
151            .expect("an active capture run owns its trace session");
152        let trace = session.finish()?;
153        self.finalize(&trace)
154    }
155
156    /// Finish the session as an explicit failed capture and still publish the
157    /// diagnosable bundle, so crashed campaign steps stay queryable.
158    pub fn publish_failed(mut self, reason: impl Into<String>) -> Result<PublicationReceipt> {
159        let session = self
160            .session
161            .take()
162            .expect("an active capture run owns its trace session");
163        let trace = session.finish_failed(reason)?;
164        self.finalize(&trace)
165    }
166
167    fn finalize(&mut self, trace: &Path) -> Result<PublicationReceipt> {
168        // The destination may have appeared since `begin` (concurrent retry).
169        if let Some(receipt) = reconcile_published_bundle(&self.destination, &self.run)? {
170            let _ = fs::remove_file(trace);
171            return Ok(receipt);
172        }
173        publish_bundle(&self.destination, trace, self.nsight_dir.as_deref()).with_context(
174            || {
175                format!(
176                    "publish capture bundle {} (staged trace retained at {})",
177                    self.destination.display(),
178                    trace.display()
179                )
180            },
181        )?;
182        let verification = verify_bundle(&self.destination).with_context(|| {
183            format!(
184                "verify published capture bundle {}",
185                self.destination.display()
186            )
187        })?;
188        let _ = fs::remove_file(trace);
189        Ok(PublicationReceipt {
190            schema: PUBLICATION_SCHEMA.into(),
191            status: PublicationStatus::Published,
192            bundle_path: self.destination.clone(),
193            run_id: verification.run_id.clone(),
194            verification,
195        })
196    }
197}
198
199/// If the destination already holds a deeply verified bundle for the same
200/// planned capture, return its receipt; a missing destination returns `None`;
201/// anything else fails closed as a conflict.
202///
203/// Retry identity is the planned-run coordinates — entrypoint, correlation ID,
204/// phase, capture step, and device — not byte equality, because a retried
205/// capture legitimately re-records timings under a new run ID.
206pub fn reconcile_published_bundle(
207    destination: &Path,
208    run: &ProfileRun,
209) -> Result<Option<PublicationReceipt>> {
210    if !destination.exists() {
211        return Ok(None);
212    }
213    let verification = verify_bundle(destination).with_context(|| {
214        format!(
215            "existing destination {} is not a verifiable evidence bundle; refusing to reuse or overwrite it",
216            destination.display()
217        )
218    })?;
219    let trace_path = destination.join("trace.jsonl");
220    let document = parse_trace(&trace_path)
221        .with_context(|| format!("parse published bundle trace {}", trace_path.display()))?;
222    ensure!(
223        verification.run_id == document.run.run_id,
224        "published bundle manifest run ID {:?} does not match its trace run ID {:?}",
225        verification.run_id,
226        document.run.run_id
227    );
228    ensure_same_planned_capture(&document.run, run, destination)?;
229    Ok(Some(PublicationReceipt {
230        schema: PUBLICATION_SCHEMA.into(),
231        status: PublicationStatus::AlreadyPublished,
232        bundle_path: destination.to_path_buf(),
233        run_id: verification.run_id.clone(),
234        verification,
235    }))
236}
237
238fn ensure_same_planned_capture(
239    published: &TraceRunMeta,
240    run: &ProfileRun,
241    destination: &Path,
242) -> Result<()> {
243    let mismatches = [
244        ("entrypoint", published.entrypoint != run.entrypoint),
245        (
246            "correlation_id",
247            published.correlation_id != run.correlation_id,
248        ),
249        ("phase", published.phase != run.phase),
250        ("capture_step", published.capture_step != run.capture_step),
251        ("device", published.device != run.device),
252    ]
253    .into_iter()
254    .filter_map(|(field, differs)| differs.then_some(field))
255    .collect::<Vec<_>>();
256    if !mismatches.is_empty() {
257        bail!(
258            "bundle destination {} already holds a different planned capture (conflicting {})",
259            destination.display(),
260            mismatches.join(", ")
261        );
262    }
263    Ok(())
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::cli::trace_cli::load_evidence;
270    use crate::trace::RunOutcome;
271
272    fn temp_root(name: &str) -> PathBuf {
273        let nonce = SystemTime::now()
274            .duration_since(UNIX_EPOCH)
275            .unwrap()
276            .as_nanos();
277        let root = std::env::temp_dir().join(format!(
278            "candle-graph-publication-{name}-{}-{nonce}",
279            std::process::id()
280        ));
281        fs::create_dir_all(&root).unwrap();
282        root
283    }
284
285    fn active(begin: CaptureBegin) -> CaptureRun {
286        match begin {
287            CaptureBegin::Active(run) => run,
288            CaptureBegin::AlreadyPublished(receipt) => {
289                panic!("expected an active capture run, got {:?}", receipt.status)
290            }
291        }
292    }
293
294    #[test]
295    fn capture_run_publishes_verified_bundle_and_cleans_staging() {
296        let root = temp_root("publish");
297        let destination = root.join("profiles/update-2");
298        let run = ProfileRun::training("train::update", 2, "cpu");
299        let capture = active(CaptureRun::begin(&destination, run.clone()).unwrap());
300        let staging = capture.staging_trace().to_path_buf();
301        {
302            let session = capture.session();
303            let measured = session.begin_measurement("update");
304            session
305                .record_scalar(measured.id(), "loss/total", 0.75)
306                .unwrap();
307        }
308        let receipt = capture.publish().unwrap();
309        assert_eq!(receipt.status, PublicationStatus::Published);
310        assert_eq!(receipt.schema, PUBLICATION_SCHEMA);
311        assert_eq!(receipt.run_id, receipt.verification.run_id);
312        assert!(destination.join("bundle.json").is_file());
313        assert!(!staging.exists(), "staging trace must be removed");
314
315        let evidence = load_evidence(&destination).unwrap();
316        assert_eq!(evidence.provenance.run_id, receipt.run_id);
317        assert_eq!(evidence.tensor_stats.len(), 1);
318
319        // Crash-retry: the same planned capture reconciles without recapturing.
320        let retry = CaptureRun::begin(&destination, run).unwrap();
321        match retry {
322            CaptureBegin::AlreadyPublished(reconciled) => {
323                assert_eq!(reconciled.status, PublicationStatus::AlreadyPublished);
324                assert_eq!(reconciled.run_id, receipt.run_id);
325                assert_eq!(
326                    reconciled.verification.manifest_sha256,
327                    receipt.verification.manifest_sha256
328                );
329            }
330            CaptureBegin::Active(_) => panic!("expected reconciliation"),
331        }
332
333        // A different planned capture at the same destination is a conflict.
334        let conflict = CaptureRun::begin(
335            &destination,
336            ProfileRun::training("train::update", 3, "cpu"),
337        )
338        .unwrap_err();
339        assert!(conflict.to_string().contains("conflicting"));
340        assert!(conflict.to_string().contains("capture_step"));
341
342        fs::remove_dir_all(root).unwrap();
343    }
344
345    #[test]
346    fn non_bundle_destination_fails_closed() {
347        let root = temp_root("conflict");
348        let destination = root.join("profiles/update-2");
349        fs::create_dir_all(&destination).unwrap();
350        fs::write(destination.join("application.jsonl"), b"not a bundle").unwrap();
351        let error = CaptureRun::begin(
352            &destination,
353            ProfileRun::training("train::update", 2, "cpu"),
354        )
355        .unwrap_err();
356        assert!(error
357            .to_string()
358            .contains("not a verifiable evidence bundle"));
359        fs::remove_dir_all(root).unwrap();
360    }
361
362    #[test]
363    fn failed_captures_publish_diagnosable_verified_bundles() {
364        let root = temp_root("failed");
365        let destination = root.join("update-7");
366        let run = ProfileRun::training("train::update", 7, "cpu");
367        let capture = active(CaptureRun::begin(&destination, run.clone()).unwrap());
368        let receipt = capture.publish_failed("loss became non-finite").unwrap();
369        assert_eq!(receipt.status, PublicationStatus::Published);
370        verify_bundle(&destination).unwrap();
371        let document = parse_trace(destination.join("trace.jsonl")).unwrap();
372        assert_eq!(document.terminal.outcome, RunOutcome::Failed);
373        assert_eq!(
374            document.terminal.reason.as_deref(),
375            Some("loss became non-finite")
376        );
377
378        // Failed publications also reconcile instead of recapturing.
379        match CaptureRun::begin(&destination, run).unwrap() {
380            CaptureBegin::AlreadyPublished(reconciled) => {
381                assert_eq!(reconciled.run_id, receipt.run_id);
382            }
383            CaptureBegin::Active(_) => panic!("expected reconciliation"),
384        }
385        fs::remove_dir_all(root).unwrap();
386    }
387
388    #[test]
389    fn nested_bundle_destinations_are_rejected() {
390        let root = temp_root("nested");
391        let outer = root.join("outer");
392        let capture = active(
393            CaptureRun::begin(&outer, ProfileRun::training("train::update", 1, "cpu")).unwrap(),
394        );
395        drop(capture.session().begin_measurement("update"));
396        capture.publish().unwrap();
397        let error = CaptureRun::begin(
398            outer.join("inner"),
399            ProfileRun::training("train::update", 2, "cpu"),
400        )
401        .unwrap_err();
402        assert!(error.to_string().contains("inside existing bundle"));
403        fs::remove_dir_all(root).unwrap();
404    }
405}