1use std::collections::BTreeSet;
12use std::fmt;
13
14use serde::{Deserialize, Serialize};
15
16use crate::ids::{PlanId, RequestId, SnapshotId};
17use crate::limits::REPLAY_V1;
18use crate::output::BackendOutputDoc;
19use crate::reason::RuntimeReasonDoc;
20use crate::retention::{MAX_BUNDLE_LIFETIME_MS, RetentionItem};
21use crate::schema;
22use crate::scope::Scope;
23use crate::time::{DurationMs, Timestamp};
24
25pub const MAX_BUNDLE_ITEMS: usize = 4096;
27pub const MAX_RESOLVED_BYTES: usize = 16 * 1024 * 1024;
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
31#[serde(deny_unknown_fields)]
32pub struct ReplayBundle {
33 pub schema: String,
34 pub decision_id: String,
35 pub scope: Scope,
36 pub created_at_ms: Timestamp,
38 pub expires_at_ms: Timestamp,
39 pub evaluation_time_ms: Timestamp,
41 pub plan_id: PlanId,
42 pub plan: RetentionItem,
43 pub descriptors: Vec<RetentionItem>,
45 pub calibrations: Vec<RetentionItem>,
47 pub snapshot_id: SnapshotId,
48 pub snapshot: RetentionItem,
49 pub run: RetentionItem,
51 pub capture: CaptureStatus,
52 pub supplies: Vec<SupplyEntry>,
54 #[serde(
56 default,
57 skip_serializing_if = "Option::is_none",
58 deserialize_with = "crate::acquisition_record::list"
59 )]
60 #[schemars(with = "Vec<RetentionItem>", length(min = 1, max = 4096))]
61 pub sources: Option<Vec<RetentionItem>>,
62 #[serde(
64 default,
65 skip_serializing_if = "Option::is_none",
66 deserialize_with = "captured_pages"
67 )]
68 #[schemars(with = "Vec<AcquisitionSupply>", length(max = 4096))]
69 pub acquisition_pages: Option<Vec<AcquisitionSupply>>,
70}
71
72crate::document::versioned_document!(
73 ReplayBundle,
74 [schema::REPLAY, schema::REPLAY_V2],
75 "rustev.replay/1 or rustev.replay/2",
76 REPLAY_V1,
77 check = ReplayBundle::parsed_members
78);
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
81#[serde(deny_unknown_fields)]
82pub struct AcquisitionSupply {
83 pub sequence: u64,
84 pub step: String,
85 pub tier: u32,
86 pub page: u32,
87 pub request: crate::ids::AcquisitionRequestId,
88 pub attempt_id: Option<String>,
89 pub value: RetentionItem,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
95#[serde(rename_all = "snake_case")]
96pub enum CaptureStatus {
97 Complete,
98 Disabled,
99 LimitExceeded,
101 Cancelled,
102 Abandoned,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
107#[serde(deny_unknown_fields)]
108pub struct SupplyEntry {
109 pub step: String,
110 pub instance: Vec<String>,
111 pub request: RequestId,
114 pub sequence: u64,
116 pub attempt_id: Option<String>,
119 pub target: u32,
123 pub value: RetentionItem,
125 #[serde(
127 default,
128 skip_serializing_if = "Option::is_none",
129 deserialize_with = "failure_context"
130 )]
131 pub failure_context: Option<CapturedFailureContext>,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
135#[serde(deny_unknown_fields)]
136pub struct CapturedFailureContext {
137 pub origin: crate::actionability::Origin,
138 pub determinism: crate::definition::Determinism,
139}
140
141#[derive(
143 Debug,
144 Clone,
145 Copy,
146 PartialEq,
147 Eq,
148 PartialOrd,
149 Ord,
150 Hash,
151 Serialize,
152 Deserialize,
153 schemars::JsonSchema,
154)]
155#[serde(rename_all = "snake_case")]
156pub enum ItemLocation {
157 Bundle,
158 Plan,
159 Descriptor(usize),
160 Calibration(usize),
161 Snapshot,
162 Run,
163 Supply(usize),
164 Source(usize),
165 AcquisitionPage(usize),
166}
167
168impl fmt::Display for ItemLocation {
169 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170 match self {
171 ItemLocation::Source(i) => write!(f, "sources[{i}]"),
172 ItemLocation::AcquisitionPage(i) => write!(f, "acquisition_pages[{i}]"),
173 ItemLocation::Bundle => f.write_str("bundle"),
174 ItemLocation::Plan => f.write_str("plan"),
175 ItemLocation::Descriptor(i) => write!(f, "descriptors[{i}]"),
176 ItemLocation::Calibration(i) => write!(f, "calibrations[{i}]"),
177 ItemLocation::Snapshot => f.write_str("snapshot"),
178 ItemLocation::Run => f.write_str("run"),
179 ItemLocation::Supply(i) => write!(f, "supplies[{i}]"),
180 }
181 }
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum LifetimeProblem {
186 NotAfterCreation,
188 ExceedsCap { cap_ms: u64 },
190 OutlivesBundle,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum BundleError {
197 Schema(String),
198 DecisionId,
200 Lifetime {
201 location: ItemLocation,
202 problem: LifetimeProblem,
203 },
204 TooMany {
205 what: &'static str,
206 count: usize,
207 },
208 Sequence {
210 index: usize,
211 },
212 DuplicateSupply {
214 index: usize,
215 },
216 Role {
218 location: ItemLocation,
219 },
220 Identity {
223 location: ItemLocation,
224 },
225 SuppliesWithoutCapture,
227}
228
229impl fmt::Display for BundleError {
230 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231 match self {
232 BundleError::Schema(s) => write!(f, "bundle schema {s:?}"),
233 BundleError::DecisionId => f.write_str("a decision id is 1 to 256 bytes"),
234 BundleError::Lifetime { location, problem } => {
235 write!(f, "lifetime of {location}: {problem:?}")
236 }
237 BundleError::TooMany { what, count } => {
238 write!(f, "{count} {what}, more than {MAX_BUNDLE_ITEMS}")
239 }
240 BundleError::Sequence { index } => {
241 write!(f, "supplies[{index}] breaks the contiguous supply sequence")
242 }
243 BundleError::DuplicateSupply { index } => {
244 write!(f, "supplies[{index}] repeats a step and instance")
245 }
246 BundleError::Role { location } => {
247 write!(f, "{location} declares a schema its role does not allow")
248 }
249 BundleError::Identity { location } => {
250 write!(
251 f,
252 "{location} declares an identity the bundle does not name"
253 )
254 }
255 BundleError::SuppliesWithoutCapture => {
256 f.write_str("a disabled or limit-exceeded capture has no supplies")
257 }
258 }
259 }
260}
261
262impl std::error::Error for BundleError {}
263
264impl ReplayBundle {
265 fn parsed_members(&self) -> Result<(), String> {
266 if self.schema == schema::REPLAY {
268 if self.sources.is_some()
269 || self.acquisition_pages.is_some()
270 || self.supplies.iter().any(|s| s.failure_context.is_some())
271 {
272 return Err("acquisition members require rustev.replay/2".into());
273 }
274 return Ok(());
275 }
276 self.check(None).map_err(|e| e.to_string())
277 }
278
279 pub fn items(&self) -> Vec<(ItemLocation, &RetentionItem)> {
281 let mut v = vec![(ItemLocation::Plan, &self.plan)];
282 v.extend(
283 self.descriptors
284 .iter()
285 .enumerate()
286 .map(|(i, d)| (ItemLocation::Descriptor(i), d)),
287 );
288 v.extend(
289 self.calibrations
290 .iter()
291 .enumerate()
292 .map(|(i, c)| (ItemLocation::Calibration(i), c)),
293 );
294 v.push((ItemLocation::Snapshot, &self.snapshot));
295 v.push((ItemLocation::Run, &self.run));
296 v.extend(
297 self.supplies
298 .iter()
299 .enumerate()
300 .map(|(i, s)| (ItemLocation::Supply(i), &s.value)),
301 );
302 if let Some(sources) = &self.sources {
303 v.extend(
304 sources
305 .iter()
306 .enumerate()
307 .map(|(i, s)| (ItemLocation::Source(i), s)),
308 );
309 }
310 if let Some(pages) = &self.acquisition_pages {
311 v.extend(
312 pages
313 .iter()
314 .enumerate()
315 .map(|(i, p)| (ItemLocation::AcquisitionPage(i), &p.value)),
316 );
317 }
318 v
319 }
320
321 pub fn check(&self, host_cap_ms: Option<u64>) -> Result<(), BundleError> {
328 if self.schema != schema::REPLAY && self.schema != schema::REPLAY_V2 {
329 return Err(BundleError::Schema(self.schema.chars().take(80).collect()));
330 }
331 let v2 = self.schema == schema::REPLAY_V2;
332 if !v2 && (self.sources.is_some() || self.acquisition_pages.is_some()) {
334 return Err(BundleError::Role {
335 location: ItemLocation::Bundle,
336 });
337 }
338 if self.decision_id.is_empty() || self.decision_id.len() > 256 {
339 return Err(BundleError::DecisionId);
340 }
341 let cap_ms = host_cap_ms.map_or(MAX_BUNDLE_LIFETIME_MS, |c| c.min(MAX_BUNDLE_LIFETIME_MS));
342 let life = |location, problem| Err(BundleError::Lifetime { location, problem });
343 if self.expires_at_ms <= self.created_at_ms {
344 return life(ItemLocation::Bundle, LifetimeProblem::NotAfterCreation);
345 }
346 let beyond = match self.created_at_ms.checked_add(DurationMs(cap_ms)) {
349 Ok(latest) => self.expires_at_ms > latest,
350 Err(_) => false,
351 };
352 if beyond {
353 return life(ItemLocation::Bundle, LifetimeProblem::ExceedsCap { cap_ms });
354 }
355 for (what, count) in [
356 ("descriptors", self.descriptors.len()),
357 ("calibrations", self.calibrations.len()),
358 ("supplies", self.supplies.len()),
359 ("sources", self.sources.as_ref().map_or(0, Vec::len)),
360 (
361 "acquisition_pages",
362 self.acquisition_pages.as_ref().map_or(0, Vec::len),
363 ),
364 ] {
365 if count > MAX_BUNDLE_ITEMS {
366 return Err(BundleError::TooMany { what, count });
367 }
368 }
369 for (location, item) in self.items() {
370 let exp = item.retention.expires_at();
371 if exp <= self.created_at_ms {
372 return life(location, LifetimeProblem::NotAfterCreation);
373 }
374 if exp > self.expires_at_ms {
375 return life(location, LifetimeProblem::OutlivesBundle);
376 }
377 let allowed: &[&str] = match location {
378 ItemLocation::Plan if v2 => &[schema::PLAN_V3],
379 ItemLocation::Snapshot if v2 => &[schema::SNAPSHOT, schema::SNAPSHOT_V2],
380 ItemLocation::Run if v2 => &[schema::RUN_V3],
381 ItemLocation::Source(_) => &[schema::SOURCE],
382 ItemLocation::AcquisitionPage(_) => {
383 &[schema::ACQUISITION_PAGE, schema::RUNTIME_REASON]
384 }
385 ItemLocation::Plan => &[schema::PLAN, schema::PLAN_V3],
386 ItemLocation::Descriptor(_) => &[schema::BACKEND],
387 ItemLocation::Calibration(_) => &[schema::CALIBRATION],
388 ItemLocation::Snapshot => &[schema::SNAPSHOT],
389 ItemLocation::Run => &[schema::RUN, schema::RUN_V3],
390 ItemLocation::Supply(_) => &[schema::BACKEND_OUTPUT, schema::RUNTIME_REASON],
391 ItemLocation::Bundle => &[],
392 };
393 if !allowed.contains(&item.document_schema.as_str()) {
394 return Err(BundleError::Role { location });
395 }
396 }
397 for (location, item, id) in [
398 (ItemLocation::Plan, &self.plan, self.plan_id.as_str()),
399 (
400 ItemLocation::Snapshot,
401 &self.snapshot,
402 self.snapshot_id.as_str(),
403 ),
404 ] {
405 if item.identity.as_ref().is_some_and(|d| d.as_str() != id) {
406 return Err(BundleError::Identity { location });
407 }
408 }
409 if matches!(
410 self.capture,
411 CaptureStatus::Disabled | CaptureStatus::LimitExceeded
412 ) && (!self.supplies.is_empty()
413 || self
414 .acquisition_pages
415 .as_ref()
416 .is_some_and(|p| !p.is_empty()))
417 {
418 return Err(BundleError::SuppliesWithoutCapture);
419 }
420 if v2
421 && (self
422 .supplies
423 .windows(2)
424 .any(|w| w[0].sequence >= w[1].sequence)
425 || self
426 .acquisition_pages
427 .as_ref()
428 .is_some_and(|pages| pages.windows(2).any(|w| w[0].sequence >= w[1].sequence)))
429 {
430 return Err(BundleError::Sequence { index: 0 });
431 }
432 let mut sequence = BTreeSet::new();
436 let mut page_keys = BTreeSet::new();
437 if let Some(pages) = &self.acquisition_pages {
438 for (i, p) in pages.iter().enumerate() {
439 if !sequence.insert(p.sequence) {
440 return Err(BundleError::Sequence { index: i });
441 }
442 if !page_keys.insert((&p.step, p.tier, p.page)) {
443 return Err(BundleError::DuplicateSupply { index: i });
444 }
445 }
446 }
447 let mut keys = BTreeSet::new();
448 for (i, s) in self.supplies.iter().enumerate() {
449 if s.failure_context.is_some()
450 && (!v2 || s.value.document_schema != schema::RUNTIME_REASON)
451 {
452 return Err(BundleError::Role {
453 location: ItemLocation::Supply(i),
454 });
455 }
456 if (!v2 && s.sequence != i as u64) || !sequence.insert(s.sequence) {
459 return Err(BundleError::Sequence { index: i });
460 }
461 if !keys.insert((&s.step, &s.instance)) {
462 return Err(BundleError::DuplicateSupply { index: i });
463 }
464 }
465 if !v2 && sequence.iter().copied().ne(0..sequence.len() as u64) {
466 return Err(BundleError::Sequence { index: 0 });
467 }
468 Ok(())
469 }
470}
471
472#[derive(Debug, Clone, PartialEq)]
475pub struct Capture {
476 pub decision_id: String,
477 pub scope: Scope,
478 pub status: CaptureStatus,
481 pub supplies: Vec<CapturedSupply>,
483 pub bytes: u64,
486 pub acquisition_pages: Vec<CapturedAcquisition>,
488}
489
490#[derive(Debug, Clone, PartialEq)]
492pub struct CapturedSupply {
493 pub sequence: u64,
494 pub step: String,
495 pub instance: Vec<String>,
496 pub request: RequestId,
497 pub attempt_id: Option<String>,
498 pub target: u32,
499 pub value: CapturedValue,
500 pub failure_context: Option<CapturedFailureContext>,
501}
502
503#[derive(Debug, Clone, PartialEq)]
504pub enum CapturedValue {
505 Output(BackendOutputDoc),
507 Reason(RuntimeReasonDoc),
508}
509
510#[derive(Debug, Clone, PartialEq)]
512pub struct CapturedAcquisition {
513 pub sequence: u64,
514 pub step: String,
515 pub tier: u32,
516 pub page: u32,
517 pub request: crate::ids::AcquisitionRequestId,
518 pub attempt_id: Option<String>,
519 pub value: CapturedPage,
520}
521
522#[derive(Debug, Clone, PartialEq)]
523pub enum CapturedPage {
524 Page(crate::acquisition::AcquisitionPage),
525 Reason(RuntimeReasonDoc),
526}
527
528fn captured_pages<'de, D: serde::Deserializer<'de>>(
530 d: D,
531) -> Result<Option<Vec<AcquisitionSupply>>, D::Error> {
532 let pages = Vec::<AcquisitionSupply>::deserialize(d)?;
533 if pages.len() > MAX_BUNDLE_ITEMS {
534 return Err(serde::de::Error::custom("too many captured pages"));
535 }
536 Ok(Some(pages))
537}
538
539fn failure_context<'de, D: serde::Deserializer<'de>>(
540 d: D,
541) -> Result<Option<CapturedFailureContext>, D::Error> {
542 CapturedFailureContext::deserialize(d).map(Some)
543}