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()) {
333 return Err(BundleError::Role {
334 location: ItemLocation::Bundle,
335 });
336 }
337 if self.decision_id.is_empty() || self.decision_id.len() > 256 {
338 return Err(BundleError::DecisionId);
339 }
340 let cap_ms = host_cap_ms.map_or(MAX_BUNDLE_LIFETIME_MS, |c| c.min(MAX_BUNDLE_LIFETIME_MS));
341 let life = |location, problem| Err(BundleError::Lifetime { location, problem });
342 if self.expires_at_ms <= self.created_at_ms {
343 return life(ItemLocation::Bundle, LifetimeProblem::NotAfterCreation);
344 }
345 let beyond = match self.created_at_ms.checked_add(DurationMs(cap_ms)) {
348 Ok(latest) => self.expires_at_ms > latest,
349 Err(_) => false,
350 };
351 if beyond {
352 return life(ItemLocation::Bundle, LifetimeProblem::ExceedsCap { cap_ms });
353 }
354 for (what, count) in [
355 ("descriptors", self.descriptors.len()),
356 ("calibrations", self.calibrations.len()),
357 ("supplies", self.supplies.len()),
358 ("sources", self.sources.as_ref().map_or(0, Vec::len)),
359 (
360 "acquisition_pages",
361 self.acquisition_pages.as_ref().map_or(0, Vec::len),
362 ),
363 ] {
364 if count > MAX_BUNDLE_ITEMS {
365 return Err(BundleError::TooMany { what, count });
366 }
367 }
368 for (location, item) in self.items() {
369 let exp = item.retention.expires_at();
370 if exp <= self.created_at_ms {
371 return life(location, LifetimeProblem::NotAfterCreation);
372 }
373 if exp > self.expires_at_ms {
374 return life(location, LifetimeProblem::OutlivesBundle);
375 }
376 let allowed: &[&str] = match location {
377 ItemLocation::Plan if v2 => &[schema::PLAN_V3],
378 ItemLocation::Snapshot if v2 => &[schema::SNAPSHOT, schema::SNAPSHOT_V2],
379 ItemLocation::Run if v2 => &[schema::RUN_V3],
380 ItemLocation::Source(_) => &[schema::SOURCE],
381 ItemLocation::AcquisitionPage(_) => {
382 &[schema::ACQUISITION_PAGE, schema::RUNTIME_REASON]
383 }
384 ItemLocation::Plan => &[schema::PLAN, schema::PLAN_V3],
385 ItemLocation::Descriptor(_) => &[schema::BACKEND],
386 ItemLocation::Calibration(_) => &[schema::CALIBRATION],
387 ItemLocation::Snapshot => &[schema::SNAPSHOT],
388 ItemLocation::Run => &[schema::RUN, schema::RUN_V3],
389 ItemLocation::Supply(_) => &[schema::BACKEND_OUTPUT, schema::RUNTIME_REASON],
390 ItemLocation::Bundle => &[],
391 };
392 if !allowed.contains(&item.document_schema.as_str()) {
393 return Err(BundleError::Role { location });
394 }
395 }
396 for (location, item, id) in [
397 (ItemLocation::Plan, &self.plan, self.plan_id.as_str()),
398 (
399 ItemLocation::Snapshot,
400 &self.snapshot,
401 self.snapshot_id.as_str(),
402 ),
403 ] {
404 if item.identity.as_ref().is_some_and(|d| d.as_str() != id) {
405 return Err(BundleError::Identity { location });
406 }
407 }
408 if matches!(
409 self.capture,
410 CaptureStatus::Disabled | CaptureStatus::LimitExceeded
411 ) && (!self.supplies.is_empty()
412 || self
413 .acquisition_pages
414 .as_ref()
415 .is_some_and(|p| !p.is_empty()))
416 {
417 return Err(BundleError::SuppliesWithoutCapture);
418 }
419 if v2
420 && (self
421 .supplies
422 .windows(2)
423 .any(|w| w[0].sequence >= w[1].sequence)
424 || self
425 .acquisition_pages
426 .as_ref()
427 .is_some_and(|pages| pages.windows(2).any(|w| w[0].sequence >= w[1].sequence)))
428 {
429 return Err(BundleError::Sequence { index: 0 });
430 }
431 let mut sequence = BTreeSet::new();
435 let mut page_keys = BTreeSet::new();
436 if let Some(pages) = &self.acquisition_pages {
437 for (i, p) in pages.iter().enumerate() {
438 if !sequence.insert(p.sequence) {
439 return Err(BundleError::Sequence { index: i });
440 }
441 if !page_keys.insert((&p.step, p.tier, p.page)) {
442 return Err(BundleError::DuplicateSupply { index: i });
443 }
444 }
445 }
446 let mut keys = BTreeSet::new();
447 for (i, s) in self.supplies.iter().enumerate() {
448 if s.failure_context.is_some()
449 && (!v2 || s.value.document_schema != schema::RUNTIME_REASON)
450 {
451 return Err(BundleError::Role {
452 location: ItemLocation::Supply(i),
453 });
454 }
455 if (!v2 && s.sequence != i as u64) || !sequence.insert(s.sequence) {
458 return Err(BundleError::Sequence { index: i });
459 }
460 if !keys.insert((&s.step, &s.instance)) {
461 return Err(BundleError::DuplicateSupply { index: i });
462 }
463 }
464 if !v2 && sequence.iter().copied().ne(0..sequence.len() as u64) {
465 return Err(BundleError::Sequence { index: 0 });
466 }
467 Ok(())
468 }
469}
470
471#[derive(Debug, Clone, PartialEq)]
474pub struct Capture {
475 pub decision_id: String,
476 pub scope: Scope,
477 pub status: CaptureStatus,
480 pub supplies: Vec<CapturedSupply>,
482 pub bytes: u64,
485 pub acquisition_pages: Vec<CapturedAcquisition>,
487}
488
489#[derive(Debug, Clone, PartialEq)]
491pub struct CapturedSupply {
492 pub sequence: u64,
493 pub step: String,
494 pub instance: Vec<String>,
495 pub request: RequestId,
496 pub attempt_id: Option<String>,
497 pub target: u32,
498 pub value: CapturedValue,
499 pub failure_context: Option<CapturedFailureContext>,
500}
501
502#[derive(Debug, Clone, PartialEq)]
503pub enum CapturedValue {
504 Output(BackendOutputDoc),
506 Reason(RuntimeReasonDoc),
507}
508
509#[derive(Debug, Clone, PartialEq)]
511pub struct CapturedAcquisition {
512 pub sequence: u64,
513 pub step: String,
514 pub tier: u32,
515 pub page: u32,
516 pub request: crate::ids::AcquisitionRequestId,
517 pub attempt_id: Option<String>,
518 pub value: CapturedPage,
519}
520
521#[derive(Debug, Clone, PartialEq)]
522pub enum CapturedPage {
523 Page(crate::acquisition::AcquisitionPage),
524 Reason(RuntimeReasonDoc),
525}
526
527fn captured_pages<'de, D: serde::Deserializer<'de>>(
529 d: D,
530) -> Result<Option<Vec<AcquisitionSupply>>, D::Error> {
531 let pages = Vec::<AcquisitionSupply>::deserialize(d)?;
532 if pages.len() > MAX_BUNDLE_ITEMS {
533 return Err(serde::de::Error::custom("too many captured pages"));
534 }
535 Ok(Some(pages))
536}
537
538fn failure_context<'de, D: serde::Deserializer<'de>>(
539 d: D,
540) -> Result<Option<CapturedFailureContext>, D::Error> {
541 CapturedFailureContext::deserialize(d).map(Some)
542}