1use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5use std::fs::{File, OpenOptions};
6use std::io::{Read, Write};
7use std::path::Path;
8use std::sync::atomic::{AtomicU64, Ordering};
9
10use rustev_contract::definition::Operation;
11use rustev_contract::ids::{ArtifactId, ContentDigest, DatasetId, PlanId, SnapshotId};
12use rustev_contract::limits::REPLAY_V1;
13use rustev_contract::{Document, Identified, Timestamp};
14use serde::{Deserialize, Serialize};
15
16pub use rustev_contract::ids::LiveConfigId;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct LiveError {
21 pub status: &'static str,
22 pub subject: &'static str,
23 pub detail: String,
24}
25
26impl LiveError {
27 pub(crate) fn invalid(subject: &'static str, detail: impl fmt::Display) -> Self {
28 Self {
29 status: "invalid_input",
30 subject,
31 detail: detail.to_string(),
32 }
33 }
34
35 fn config(detail: impl fmt::Display) -> Self {
36 Self::invalid("live-config", detail)
37 }
38
39 pub(crate) fn io(detail: impl fmt::Display) -> Self {
40 Self {
41 status: "io_error",
42 subject: "checkpoint",
43 detail: detail.to_string(),
44 }
45 }
46}
47
48impl fmt::Display for LiveError {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 write!(f, "{}: {}: {}", self.status, self.subject, self.detail)
51 }
52}
53impl std::error::Error for LiveError {}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Mode {
57 Cli,
58 Library,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum Budget {
64 Testing,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case", deny_unknown_fields)]
69pub enum EvaluationTime {
70 Source,
71 Fixed { ms: Timestamp },
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum CostUnit {
77 NanoUsd,
78 None,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum AcquisitionMode {
84 Live,
85 Captured,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct BindingIdentity {
91 pub backend_id: String,
92 pub artifact: ArtifactId,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct Binding {
98 pub identity: BindingIdentity,
99 pub cost_unit: CostUnit,
100 pub max_in_flight: u64,
101 pub min_interval_ms: u64,
102 pub case_deadline_ms: u64,
103 pub rate_limit_pause_max_ms: u64,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum ProbeProvenance {
109 Synthetic,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct Probe {
115 #[serde(default = "default_probe_calls")]
116 pub calls: u64,
117 pub interval_ms: u64,
118 pub snapshot: String,
119 pub digest: SnapshotId,
120 pub provenance: ProbeProvenance,
121}
122fn default_probe_calls() -> u64 {
123 3
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct AuthorizationRule {
131 pub backend_id: String,
132 pub artifact: ArtifactId,
133 pub operations: Vec<Operation>,
134 pub max_exposure_units: u64,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct LiveConfig {
140 pub schema: String,
141 pub budget: Budget,
142 pub run_cap_units: u64,
143 pub repeats: u64,
144 pub evaluation_time: EvaluationTime,
145 pub bindings: Vec<Binding>,
146 pub acquisitions: BTreeMap<String, AcquisitionMode>,
147 pub probe: Probe,
148 #[serde(
149 default,
150 skip_serializing_if = "Option::is_none",
151 deserialize_with = "present_authorize"
152 )]
153 pub authorize: Option<Vec<AuthorizationRule>>,
154 #[serde(default)]
155 pub compare_with_source: bool,
156}
157
158fn present_authorize<'de, D: serde::Deserializer<'de>>(
161 d: D,
162) -> Result<Option<Vec<AuthorizationRule>>, D::Error> {
163 Vec::deserialize(d).map(Some)
164}
165
166macro_rules! live_document {
167 ($ty:ty, $schema:literal) => {
168 impl Document for $ty {
169 const SCHEMA: &'static str = $schema;
170 const LIMITS: rustev_contract::bounded::ParseLimits = REPLAY_V1;
171 fn schema(&self) -> &str {
172 &self.schema
173 }
174 }
175 };
176}
177live_document!(LiveConfig, "rustev.live-config/1");
178impl Identified for LiveConfig {
179 type Id = LiveConfigId;
180 fn wrap(digest: String) -> Self::Id {
181 LiveConfigId::parse(&digest).expect("canonical digest is a validated identity")
182 }
183}
184
185pub type Candidates = BTreeMap<String, Vec<BindingIdentity>>;
188
189impl LiveConfig {
190 pub fn parse(bytes: &[u8]) -> Result<Self, LiveError> {
194 <Self as Document>::parse(bytes).map_err(|e| {
195 if probe_provenance_undeclared(bytes) {
196 LiveError::invalid("probe-snapshot", "probe.provenance must be synthetic")
197 } else {
198 LiveError::config(e)
199 }
200 })
201 }
202
203 pub fn check(&self, mode: Mode, candidates: &Candidates) -> Result<(), LiveError> {
204 if self.schema != Self::SCHEMA {
205 return Err(LiveError::config("wrong schema"));
206 }
207 if (mode == Mode::Cli) != self.authorize.is_some() {
208 return Err(LiveError::config("authorize is required only in CLI mode"));
209 }
210 if self.run_cap_units == 0
211 || !(1..=64).contains(&self.repeats)
212 || !(1..=5).contains(&self.probe.calls)
213 || self.probe.interval_ms > 60_000
214 {
215 return Err(LiveError::config("run or probe value out of range"));
216 }
217 let mut names = BTreeSet::new();
218 for name in candidates.keys() {
219 component(name, 32, true)?;
220 if !names.insert(name.to_ascii_lowercase()) {
221 return Err(LiveError::config(
222 "candidate names collide ignoring ASCII case",
223 ));
224 }
225 }
226 let reached: BTreeSet<_> = candidates.values().flatten().collect();
227 let mut configured = BTreeSet::new();
228 for binding in &self.bindings {
229 if !configured.insert(&binding.identity) {
230 return Err(LiveError::config("duplicate binding"));
231 }
232 if !(1..=64).contains(&binding.max_in_flight)
233 || binding.min_interval_ms > 600_000
234 || !(1..=3_600_000).contains(&binding.case_deadline_ms)
235 || binding.rate_limit_pause_max_ms > 600_000
236 {
237 return Err(LiveError::config("binding value out of range"));
238 }
239 }
240 if configured != reached {
241 return Err(LiveError::config(
242 "configured bindings differ from reachable bindings",
243 ));
244 }
245 Ok(())
246 }
247}
248
249fn probe_provenance_undeclared(bytes: &[u8]) -> bool {
252 bytes.len() <= <LiveConfig as Document>::LIMITS.max_bytes
253 && serde_json::from_slice::<serde_json::Value>(bytes)
254 .ok()
255 .and_then(|v| {
256 let probe = v.get("probe")?.as_object()?;
257 Some(probe.get("provenance").and_then(|p| p.as_str()) != Some("synthetic"))
258 })
259 .unwrap_or(false)
260}
261
262fn component(value: &str, max: usize, starts_alphanumeric: bool) -> Result<(), LiveError> {
265 if value.is_empty()
266 || value.len() > max
267 || (starts_alphanumeric && !value.as_bytes()[0].is_ascii_alphanumeric())
268 || !value
269 .bytes()
270 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
271 {
272 return Err(LiveError::config("invalid decision id component"));
273 }
274 Ok(())
275}
276
277pub fn decision_id(
279 run_id: &str,
280 candidate: &str,
281 repeat: u64,
282 case_id: &str,
283 rerun: Option<u64>,
284) -> Result<String, LiveError> {
285 component(run_id, 64, false)?;
286 component(candidate, 32, true)?;
287 component(case_id, 128, true)?;
288 if !(1..=64).contains(&repeat) || rerun.is_some_and(|k| !(1..=99).contains(&k)) {
289 return Err(LiveError::config("repeat or rerun out of range"));
290 }
291 let mut id = format!("live/{run_id}/{candidate}/{repeat}/{case_id}");
292 if let Some(k) = rerun {
293 id.push_str(&format!("/rerun-{k}"));
294 }
295 Ok(id)
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(deny_unknown_fields)]
300pub struct CandidateBinding {
301 pub name: String,
302 pub plan_id: PlanId,
303 pub bindings: Vec<BindingIdentity>,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(deny_unknown_fields)]
309pub struct CheckpointBindings {
310 pub run_id: String,
311 pub live_config_id: LiveConfigId,
312 pub dataset_id: DatasetId,
313 pub split: String,
314 pub candidates: Vec<CandidateBinding>,
315 pub journal_header_digest: ContentDigest,
316 pub input_digests: BTreeMap<String, ContentDigest>,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(rename_all = "snake_case", deny_unknown_fields)]
321pub enum ItemState {
322 Pending,
323 Completed {
324 live_document_digest: ContentDigest,
325 run_record_digest: ContentDigest,
326 },
327 Unavailable {
328 reason: String,
329 },
330 Refused {
331 code: String,
332 },
333 Interrupted {
334 attempt_ids: Vec<String>,
335 },
336 Rerun {
338 k: u64,
339 },
340}
341
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343#[serde(deny_unknown_fields)]
344pub struct CheckpointItem {
345 pub candidate: String,
346 pub repeat: u64,
347 pub case_id: String,
348 pub states: Vec<ItemState>,
350}
351
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(deny_unknown_fields)]
354pub struct LiveCheckpoint {
355 pub schema: String,
356 pub bindings: CheckpointBindings,
357 pub items: Vec<CheckpointItem>,
358}
359live_document!(LiveCheckpoint, "rustev.live-checkpoint/1");
360
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(deny_unknown_fields)]
364pub struct LiveCaseDocument {
365 pub schema: String,
366 pub mode: CaseMode,
367 pub purpose: CasePurpose,
368 pub source: CaseSource,
369 pub run: CaseRun,
370 pub evaluation_time: CaseEvaluationTime,
371 pub acquisitions: BTreeMap<String, CaseAcquisition>,
372 pub spend: Vec<CaseSpend>,
373 pub declared_no_spend: Vec<BindingIdentity>,
374 pub live_document_digest: ContentDigest,
375 pub run_record_digest: ContentDigest,
376}
377live_document!(LiveCaseDocument, "rustev.live-case/1");
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(rename_all = "snake_case")]
381pub enum CaseMode {
382 LiveReexecution,
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
386#[serde(rename_all = "snake_case")]
387pub enum CasePurpose {
388 Evaluation,
389 Probe,
390}
391
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
393#[serde(rename_all = "snake_case", deny_unknown_fields)]
394pub enum CaseSource {
395 None,
396 DatasetCase {
397 dataset_id: DatasetId,
398 case_id: String,
399 split: String,
400 },
401 ReplayBundle {
402 bundle_digest: ContentDigest,
403 decision_id: String,
404 },
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
408#[serde(deny_unknown_fields)]
409pub struct CaseRun {
410 pub run_id: String,
411 pub live_config_id: LiveConfigId,
412 pub candidate: String,
413 #[serde(skip_serializing_if = "Option::is_none")]
414 pub repeat: Option<u64>,
415 #[serde(skip_serializing_if = "Option::is_none")]
416 pub rerun: Option<u64>,
417}
418
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
420#[serde(rename_all = "snake_case")]
421pub enum CaseEvaluationTime {
422 Source,
423 Fixed,
424}
425
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427#[serde(rename_all = "snake_case", deny_unknown_fields)]
428pub enum CaseAcquisition {
429 Live,
430 Captured { capture_digests: Vec<ContentDigest> },
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
434#[serde(rename_all = "snake_case", deny_unknown_fields)]
435pub enum CaseSettlement {
436 Observed { units: u64 },
437 Estimated { units: u64 },
438 Unknown,
439 Released,
440}
441
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(deny_unknown_fields)]
444pub struct CaseRefusal {
445 pub code: String,
446}
447
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
452#[serde(untagged, from = "CaseSpendWire")]
453pub enum CaseSpend {
454 Reserved {
455 attempt_id: String,
456 seq: u64,
457 reserved_units: u64,
458 settlement: CaseSettlement,
459 },
460 Refused {
461 attempt_id: String,
462 refused: CaseRefusal,
463 },
464}
465
466#[derive(Deserialize)]
467#[serde(deny_unknown_fields)]
468struct ReservedWire {
469 attempt_id: String,
470 seq: u64,
471 reserved_units: u64,
472 settlement: CaseSettlement,
473}
474
475#[derive(Deserialize)]
476#[serde(deny_unknown_fields)]
477struct RefusedWire {
478 attempt_id: String,
479 refused: CaseRefusal,
480}
481
482#[derive(Deserialize)]
483#[serde(untagged)]
484enum CaseSpendWire {
485 Reserved(ReservedWire),
486 Refused(RefusedWire),
487}
488
489impl From<CaseSpendWire> for CaseSpend {
490 fn from(wire: CaseSpendWire) -> Self {
491 match wire {
492 CaseSpendWire::Reserved(r) => CaseSpend::Reserved {
493 attempt_id: r.attempt_id,
494 seq: r.seq,
495 reserved_units: r.reserved_units,
496 settlement: r.settlement,
497 },
498 CaseSpendWire::Refused(r) => CaseSpend::Refused {
499 attempt_id: r.attempt_id,
500 refused: r.refused,
501 },
502 }
503 }
504}
505
506impl LiveCaseDocument {
507 pub fn check(&self) -> Result<(), LiveError> {
508 use rustev_contract::live::{MAX_EFFECTS, MAX_TEXT_BYTES};
509 let invalid = || LiveError::invalid("live-case", "invalid identity, purpose or bound");
510 let text = |s: &str| !s.is_empty() && s.len() <= MAX_TEXT_BYTES;
511 if self.schema != Self::SCHEMA
512 || self.spend.len() > MAX_EFFECTS
513 || self.acquisitions.len() > MAX_EFFECTS
514 || self.declared_no_spend.len() > MAX_EFFECTS
515 || self.run.repeat.is_some_and(|n| !(1..=64).contains(&n))
516 || self.run.rerun.is_some_and(|n| !(1..=99).contains(&n))
517 {
518 return Err(invalid());
519 }
520 component(&self.run.run_id, 64, false).map_err(|_| invalid())?;
521 component(&self.run.candidate, 32, true).map_err(|_| invalid())?;
522 match (&self.purpose, &self.source) {
523 (CasePurpose::Probe, CaseSource::None)
524 if self.run.repeat.is_none() && self.run.rerun.is_none() => {}
525 (CasePurpose::Evaluation, CaseSource::DatasetCase { case_id, split, .. })
526 if self.run.repeat.is_some() && text(split) =>
527 {
528 component(case_id, 128, true).map_err(|_| invalid())?;
529 }
530 (CasePurpose::Evaluation, CaseSource::ReplayBundle { decision_id, .. })
531 if self.run.repeat.is_some() && text(decision_id) => {}
532 _ => return Err(invalid()),
533 }
534 let mut seqs = BTreeSet::new();
535 let mut refusals = BTreeSet::new();
536 for entry in &self.spend {
537 let attempt = match entry {
538 CaseSpend::Reserved {
539 attempt_id, seq, ..
540 } => {
541 if !seqs.insert(seq) {
542 return Err(invalid());
543 }
544 attempt_id
545 }
546 CaseSpend::Refused {
547 attempt_id,
548 refused,
549 } => {
550 if !text(&refused.code) {
551 return Err(invalid());
552 }
553 attempt_id
554 }
555 };
556 if attempt.is_empty() || attempt.len() > 4 * MAX_TEXT_BYTES {
557 return Err(invalid());
558 }
559 }
560 let reserved: BTreeSet<&str> = self
564 .spend
565 .iter()
566 .filter_map(|s| match s {
567 CaseSpend::Reserved { attempt_id, .. } => Some(attempt_id.as_str()),
568 CaseSpend::Refused { .. } => None,
569 })
570 .collect();
571 for entry in &self.spend {
572 if let CaseSpend::Refused { attempt_id, .. } = entry
573 && (reserved.contains(attempt_id.as_str()) || !refusals.insert(attempt_id))
574 {
575 return Err(invalid());
576 }
577 }
578 for (source, acquisition) in &self.acquisitions {
579 if !text(source)
580 || matches!(acquisition, CaseAcquisition::Captured { capture_digests } if capture_digests.len() > MAX_EFFECTS)
581 {
582 return Err(invalid());
583 }
584 }
585 let mut bindings = BTreeSet::new();
586 for binding in &self.declared_no_spend {
587 if !text(&binding.backend_id) || !bindings.insert(binding) {
588 return Err(invalid());
589 }
590 }
591 Ok(())
592 }
593
594 pub(crate) fn from_result(
595 config: &LiveConfig,
596 run: CaseRun,
597 source: CaseSource,
598 journal: &crate::journal::SpendJournal,
599 result: &rustev_runtime::LiveResult,
600 sources: &rustev_runtime::SourceOutcome,
601 ) -> Result<Self, LiveError> {
602 use rustev_contract::live::{AuthorizationOutcome, Dispatch};
603 let mut spend = journal.reservations_detail(&result.document.decision_id);
604 for effect in &result.document.effects {
605 if spend.iter().any(|s| matches!(s, CaseSpend::Reserved { attempt_id, .. } if attempt_id == &effect.attempt_id)) {
606 continue;
607 }
608 let code = match &effect.authorization {
609 AuthorizationOutcome::Refused { .. } => Some("authorization_refused"),
610 AuthorizationOutcome::Allowed { .. }
611 if effect.dispatch == Dispatch::NotDispatched =>
612 {
613 result.spend_refusal.as_ref().map(|r| r.code.as_str())
614 }
615 _ => None,
616 };
617 if let Some(code) = code {
618 spend.push(CaseSpend::Refused {
619 attempt_id: effect.attempt_id.clone(),
620 refused: CaseRefusal { code: code.into() },
621 });
622 }
623 }
624 spend.extend(
625 sources
626 .refusals
627 .iter()
628 .map(|(attempt_id, code)| CaseSpend::Refused {
629 attempt_id: attempt_id.clone(),
630 refused: CaseRefusal { code: code.clone() },
631 }),
632 );
633 let doc = Self {
634 schema: Self::SCHEMA.into(),
635 mode: CaseMode::LiveReexecution,
636 purpose: if source == CaseSource::None {
637 CasePurpose::Probe
638 } else {
639 CasePurpose::Evaluation
640 },
641 source,
642 run,
643 evaluation_time: match config.evaluation_time {
644 EvaluationTime::Source => CaseEvaluationTime::Source,
645 EvaluationTime::Fixed { .. } => CaseEvaluationTime::Fixed,
646 },
647 acquisitions: config
648 .acquisitions
649 .iter()
650 .map(|(source, mode)| {
651 (
652 source.clone(),
653 match mode {
654 AcquisitionMode::Live => CaseAcquisition::Live,
655 AcquisitionMode::Captured => CaseAcquisition::Captured {
656 capture_digests: sources
657 .captures
658 .get(source)
659 .cloned()
660 .unwrap_or_default(),
661 },
662 },
663 )
664 })
665 .collect(),
666 spend,
667 declared_no_spend: config
668 .bindings
669 .iter()
670 .filter(|b| b.cost_unit == CostUnit::None)
671 .map(|b| b.identity.clone())
672 .collect(),
673 live_document_digest: result.document.record_digest().map_err(LiveError::io)?,
674 run_record_digest: result.record.record_digest().map_err(LiveError::io)?,
675 };
676 doc.check()?;
677 Ok(doc)
678 }
679
680 pub(crate) fn write_result(
681 &self,
682 stem: &Path,
683 result: &rustev_runtime::LiveResult,
684 ) -> Result<ContentDigest, LiveError> {
685 self.check()?;
686 let bytes = self.record_canonical().map_err(LiveError::io)?;
687 Self::parse(&bytes).map_err(LiveError::io)?;
688 for (suffix, bytes) in [
689 (
690 "live",
691 result.document.record_canonical().map_err(LiveError::io)?,
692 ),
693 (
694 "run",
695 result.record.record_canonical().map_err(LiveError::io)?,
696 ),
697 ("case", bytes),
698 ] {
699 let name = format!(
700 "{}.{suffix}.json",
701 stem.file_name().expect("file stem").to_string_lossy()
702 );
703 write_atomic(&stem.with_file_name(name), &bytes)?;
704 }
705 self.record_digest().map_err(LiveError::io)
706 }
707
708 pub(crate) fn read(path: &Path) -> Result<Self, LiveError> {
709 let mut bytes = Vec::new();
710 File::open(path)
711 .map_err(LiveError::io)?
712 .take(<Self as Document>::LIMITS.max_bytes as u64 + 1)
715 .read_to_end(&mut bytes)
716 .map_err(LiveError::io)?;
717 let doc = Self::parse(&bytes).map_err(LiveError::io)?;
718 doc.check()?;
719 Ok(doc)
720 }
721}
722
723#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
725#[serde(deny_unknown_fields)]
726pub struct LiveRunCase {
727 pub case_document_digest: ContentDigest,
728 pub candidate: String,
729 pub repeat: u64,
730 pub case_id: String,
731 pub rerun: Option<u64>,
732 pub live_document_digest: ContentDigest,
733 pub run_record_digest: ContentDigest,
734}
735
736#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
740#[serde(deny_unknown_fields)]
741pub struct LiveRun {
742 pub schema: String,
743 pub declared_no_spend: Vec<BindingIdentity>,
744 pub identities: CheckpointBindings,
745 pub status: crate::run::RunStatus,
746 pub stop_reason: Option<String>,
747 pub probe: crate::probe::ProbeReport,
748 pub counts: BTreeMap<String, u64>,
749 pub run_spend: crate::journal::RunSpend,
750 pub journal_before: crate::journal::SpendSummary,
751 pub journal_after: crate::journal::SpendSummary,
752 pub cases: Vec<LiveRunCase>,
753 pub repeat_reports: Vec<ContentDigest>,
755}
756live_document!(LiveRun, "rustev.live-run/1");
757
758impl LiveRun {
759 pub fn check(&self) -> Result<(), LiveError> {
760 if self.schema != Self::SCHEMA {
761 return Err(LiveError::invalid("live-run", "wrong schema"));
762 }
763 let mut cases = BTreeSet::new();
764 for case in &self.cases {
765 if !cases.insert((&case.candidate, case.repeat, &case.case_id, case.rerun)) {
766 return Err(LiveError::invalid("live-run", "duplicate case"));
767 }
768 }
769 Ok(())
770 }
771}
772
773impl LiveCheckpoint {
774 pub fn check(&self) -> Result<(), LiveError> {
775 let invalid = |detail: &str| LiveError::invalid("checkpoint-mismatch", detail);
776 if self.schema != Self::SCHEMA {
777 return Err(invalid("wrong checkpoint schema"));
778 }
779 component(&self.bindings.run_id, 64, false).map_err(|e| invalid(&e.detail))?;
780 let mut names = BTreeSet::new();
781 for c in &self.bindings.candidates {
782 component(&c.name, 32, true).map_err(|e| invalid(&e.detail))?;
783 if !names.insert(&c.name) {
784 return Err(invalid("duplicate candidate"));
785 }
786 }
787 let mut items = BTreeSet::new();
788 for item in &self.items {
789 decision_id(
790 &self.bindings.run_id,
791 &item.candidate,
792 item.repeat,
793 &item.case_id,
794 None,
795 )
796 .map_err(|e| invalid(&e.detail))?;
797 if !names.contains(&item.candidate)
798 || item.states.is_empty()
799 || !items.insert((&item.candidate, item.repeat, &item.case_id))
800 {
801 return Err(invalid("invalid or duplicate work item"));
802 }
803 let mut last = 0;
804 for state in &item.states {
805 if let ItemState::Rerun { k } = state {
806 if *k != last + 1 || *k > 99 {
807 return Err(invalid("invalid rerun sequence"));
808 }
809 last = *k;
810 }
811 }
812 }
813 Ok(())
814 }
815
816 pub fn read(path: impl AsRef<Path>, expected: &CheckpointBindings) -> Result<Self, LiveError> {
818 let mut bytes = Vec::new();
819 File::open(path)
820 .map_err(LiveError::io)?
821 .take(<Self as Document>::LIMITS.max_bytes as u64 + 1)
824 .read_to_end(&mut bytes)
825 .map_err(LiveError::io)?;
826 let doc = Self::parse(&bytes).map_err(|e| LiveError::invalid("checkpoint-mismatch", e))?;
827 if &doc.bindings != expected {
828 return Err(LiveError::invalid(
829 "checkpoint-mismatch",
830 "checkpoint bindings differ",
831 ));
832 }
833 doc.check()?;
834 Ok(doc)
835 }
836
837 pub fn write_atomic(&self, path: impl AsRef<Path>) -> Result<(), LiveError> {
840 self.check()?;
841 let bytes = self.canonical().map_err(LiveError::io)?;
842 Self::parse(&bytes).map_err(|e| LiveError::invalid("checkpoint-mismatch", e))?;
844 write_atomic(path.as_ref(), &bytes)
845 }
846}
847
848pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), LiveError> {
849 let parent = path
850 .parent()
851 .filter(|p| !p.as_os_str().is_empty())
852 .unwrap_or(Path::new("."));
853 static NEXT: AtomicU64 = AtomicU64::new(0);
854 let (temp, mut file) = loop {
855 let temp = parent.join(format!(
856 ".rustev-checkpoint-{}-{}.tmp",
857 std::process::id(),
858 NEXT.fetch_add(1, Ordering::Relaxed)
859 ));
860 match OpenOptions::new().write(true).create_new(true).open(&temp) {
861 Ok(file) => break (temp, file),
862 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
863 Err(e) => return Err(LiveError::io(e)),
864 }
865 };
866 #[cfg(feature = "fault-injection")]
867 let name = path.file_name().unwrap().to_string_lossy();
868 let written = (|| {
869 #[cfg(feature = "fault-injection")]
870 crate::fault_injection::point("atomic", &name, "before-write")?;
871 file.write_all(bytes)?;
872 file.sync_all()?;
873 drop(file);
874 #[cfg(feature = "fault-injection")]
875 crate::fault_injection::point("atomic", &name, "before-rename")?;
876 std::fs::rename(&temp, path)
877 })();
878 if let Err(e) = written {
879 let _ = std::fs::remove_file(&temp);
881 return Err(LiveError::io(e));
882 }
883 #[cfg(feature = "fault-injection")]
888 crate::fault_injection::point("atomic", &name, "before-directory-sync")
889 .map_err(LiveError::io)?;
890 File::open(parent)
891 .and_then(|d| d.sync_all())
892 .map_err(LiveError::io)?;
893 #[cfg(feature = "fault-injection")]
894 crate::fault_injection::point("atomic", &name, "after-directory-sync")
895 .map_err(LiveError::io)?;
896 Ok(())
897}