1use std::fmt;
11
12use rustev_contract::Document;
13use rustev_contract::Identified;
14use rustev_contract::output::BackendOutputDoc;
15use rustev_contract::reason::RuntimeReasonDoc;
16use rustev_contract::replay::{
17 Capture, CaptureStatus, CapturedAcquisition, CapturedPage, CapturedSupply, CapturedValue,
18 MAX_BUNDLE_ITEMS,
19};
20use rustev_contract::run::RequestRecord;
21use rustev_contract::schema;
22use rustev_contract::scope::Scope;
23use rustev_core::evaluate::{Evaluation, Supplied};
24
25use crate::PreparedPlan;
26
27pub const MAX_CAPTURE_BYTES: u64 = 16 * 1024 * 1024;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct CaptureConfig {
35 pub scope: Scope,
36 pub max_bytes: u64,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum CaptureConfigError {
43 ZeroLimit,
44 LimitTooLarge {
45 max_bytes: u64,
46 },
47 CaptureUnsupported {
49 plan_schema: &'static str,
50 },
51}
52
53impl fmt::Display for CaptureConfigError {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 match self {
56 CaptureConfigError::ZeroLimit => f.write_str("a capture byte limit is positive"),
57 CaptureConfigError::LimitTooLarge { max_bytes } => write!(
58 f,
59 "a capture byte limit is at most {MAX_CAPTURE_BYTES}, not {max_bytes}"
60 ),
61 CaptureConfigError::CaptureUnsupported { plan_schema } => {
62 write!(
63 f,
64 "capture_unsupported: no replay bundle admits a {plan_schema}"
65 )
66 }
67 }
68 }
69}
70
71impl std::error::Error for CaptureConfigError {}
72
73impl CaptureConfig {
74 pub fn check(&self) -> Result<(), CaptureConfigError> {
75 if self.max_bytes == 0 {
76 return Err(CaptureConfigError::ZeroLimit);
77 }
78 if self.max_bytes > MAX_CAPTURE_BYTES {
79 return Err(CaptureConfigError::LimitTooLarge {
80 max_bytes: self.max_bytes,
81 });
82 }
83 Ok(())
84 }
85}
86
87pub(crate) struct Capturing<'a> {
90 config: &'a CaptureConfig,
91 bytes: u64,
92 exceeded: bool,
93 supplies: Vec<CapturedSupply>,
94 pages: Vec<CapturedAcquisition>,
95}
96
97impl<'a> Capturing<'a> {
98 pub fn new(config: &'a CaptureConfig) -> Self {
99 Capturing {
100 config,
101 bytes: 0,
102 exceeded: false,
103 supplies: vec![],
104 pages: vec![],
105 }
106 }
107
108 pub fn record(
111 &mut self,
112 ev: &Evaluation<'_>,
113 plan: &PreparedPlan,
114 record: &RequestRecord,
115 supplied: &Supplied,
116 failure_context: Option<rustev_core::FailureContext>,
117 ) {
118 if self.exceeded {
119 return;
120 }
121 let request = record.supplied_from().and_then(|from| {
122 ev.request_document(
123 &record.step,
124 &record.instance,
125 from.target,
126 &self.config.scope,
127 )
128 .ok()?
129 .id()
130 .ok()
131 });
132 let mut entry = request.and_then(|request| self.entry(request, plan, record, supplied));
133 if let Some((entry, _)) = &mut entry
134 && plan.compiled.plan.schema == schema::PLAN_V3
135 {
136 entry.failure_context =
137 failure_context.map(|c| rustev_contract::replay::CapturedFailureContext {
138 origin: c.origin,
139 determinism: c.determinism,
140 });
141 }
142 self.push(entry);
143 }
144
145 pub fn record_generation(
147 &mut self,
148 stage: &rustev_core::generation_eval::GenerationEvaluation<'_>,
149 plan: &PreparedPlan,
150 record: &RequestRecord,
151 supplied: &Supplied,
152 ) {
153 if self.exceeded {
154 return;
155 }
156 let request = record.supplied_from().and_then(|from| {
157 stage
158 .request_document(
159 &record.step,
160 &record.instance,
161 from.target,
162 &self.config.scope,
163 )
164 .ok()?
165 .id()
166 .ok()
167 });
168 let entry = request.and_then(|request| self.entry(request, plan, record, supplied));
169 self.push(entry);
170 }
171
172 fn push(&mut self, entry: Option<(CapturedSupply, u64)>) {
177 if self.exceeded {
178 return;
179 }
180 let Some((entry, bytes)) = entry else {
181 self.discard();
182 return;
183 };
184 self.bytes = self.bytes.saturating_add(bytes);
185 if self.bytes > self.config.max_bytes || self.supplies.len() >= MAX_BUNDLE_ITEMS {
186 self.discard();
187 } else {
188 self.supplies.push(entry);
189 }
190 }
191
192 fn discard(&mut self) {
193 self.exceeded = true;
194 self.supplies = vec![];
195 self.pages = vec![];
196 }
197
198 fn entry(
199 &self,
200 request: rustev_contract::ids::RequestId,
201 plan: &PreparedPlan,
202 record: &RequestRecord,
203 supplied: &Supplied,
204 ) -> Option<(CapturedSupply, u64)> {
205 let from = record.supplied_from()?;
206 let (value, len) = match supplied {
207 Supplied::Output(raw) => {
208 let artifact = plan
209 .steps
210 .get(&record.step)?
211 .targets
212 .get(from.target as usize)?
213 .artifact
214 .clone();
215 let out = BackendOutputDoc {
216 schema: schema::BACKEND_OUTPUT.into(),
217 step: record.step.clone(),
218 instance: record.instance.clone(),
219 artifact,
220 output: raw.clone(),
221 };
222 let len = out.record_canonical().ok()?.len();
223 (CapturedValue::Output(out), len)
224 }
225 Supplied::Failed(u) => {
226 let r = RuntimeReasonDoc::new(u.clone()).ok()?;
227 let len = r.record_canonical().ok()?.len();
228 (CapturedValue::Reason(r), len)
229 }
230 };
231 let bytes = (len + request.as_str().len()) as u64;
232 Some((
233 CapturedSupply {
234 failure_context: None,
235 sequence: (self.supplies.len() + self.pages.len()) as u64,
236 step: record.step.clone(),
237 instance: record.instance.clone(),
238 request,
239 attempt_id: from
240 .attempt
241 .map(|a| a.attempt_id.clone())
242 .or_else(|| from.reused_from.map(str::to_string)),
243 target: from.target,
244 value,
245 },
246 bytes,
247 ))
248 }
249
250 pub fn record_page(
252 &mut self,
253 record: &rustev_contract::acquisition_record::AcquisitionRecord,
254 bytes: Option<&[u8]>,
255 ) {
256 use rustev_contract::acquisition_record::AcquisitionResult;
257 if self.exceeded || matches!(record.result, AcquisitionResult::NotSupplied) {
258 return;
259 }
260 let value = match (&record.result, bytes) {
261 (AcquisitionResult::Page, Some(bytes)) => {
262 rustev_contract::acquisition::AcquisitionPage::parse(bytes)
263 .ok()
264 .map(CapturedPage::Page)
265 }
266 (AcquisitionResult::Failed(reason), _) => {
267 Some(CapturedPage::Reason(RuntimeReasonDoc {
268 schema: schema::RUNTIME_REASON.into(),
269 reason: reason.clone(),
270 }))
271 }
272 _ => None,
273 };
274 let Some(value) = value else {
275 self.discard();
276 return;
277 };
278 let canonical = match &value {
279 CapturedPage::Page(p) => p.record_canonical(),
280 CapturedPage::Reason(r) => r.record_canonical(),
281 };
282 let Ok(bytes) = canonical else {
283 self.discard();
284 return;
285 };
286 self.bytes = self
287 .bytes
288 .saturating_add((bytes.len() + record.request.as_str().len()) as u64);
289 if self.bytes > self.config.max_bytes || self.pages.len() >= MAX_BUNDLE_ITEMS {
293 self.discard();
294 return;
295 }
296 self.pages.push(CapturedAcquisition {
297 sequence: (self.supplies.len() + self.pages.len()) as u64,
298 step: record.step.clone(),
299 tier: record.tier,
300 page: record.page,
301 request: record.request.clone(),
302 attempt_id: record.attempts.last().map(|a| a.attempt_id.clone()),
303 value,
304 });
305 }
306
307 pub fn finish(self, decision_id: &str, cancelled: bool) -> Capture {
309 let status = if self.exceeded {
310 CaptureStatus::LimitExceeded
311 } else if cancelled {
312 CaptureStatus::Cancelled
313 } else {
314 CaptureStatus::Complete
315 };
316 Capture {
317 decision_id: decision_id.to_string(),
318 scope: self.config.scope.clone(),
319 status,
320 supplies: self.supplies,
321 acquisition_pages: self.pages,
322 bytes: self.bytes,
323 }
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use rustev_contract::ids::RequestId;
331 use rustev_contract::scope::Handle;
332
333 fn config(max_bytes: u64) -> CaptureConfig {
334 CaptureConfig {
335 scope: Scope::tenant_only(Handle::new("t").unwrap(), Handle::new("r").unwrap()),
336 max_bytes,
337 }
338 }
339
340 fn entry(i: u64) -> Option<(CapturedSupply, u64)> {
341 let reason =
342 RuntimeReasonDoc::new(rustev_contract::judgment::Unresolved::DeadlineExceeded).unwrap();
343 Some((
344 CapturedSupply {
345 failure_context: None,
346 sequence: i,
347 step: format!("s{i}"),
348 instance: vec![],
349 request: RequestId::parse(&format!("sha256:{}", "a".repeat(64))).unwrap(),
350 attempt_id: None,
351 target: 0,
352 value: CapturedValue::Reason(reason),
353 },
354 10,
355 ))
356 }
357
358 #[test]
359 fn config_bounds_are_inclusive() {
360 assert_eq!(config(0).check(), Err(CaptureConfigError::ZeroLimit));
361 assert!(config(1).check().is_ok());
362 assert!(config(MAX_CAPTURE_BYTES).check().is_ok());
363 assert!(config(MAX_CAPTURE_BYTES + 1).check().is_err());
364 }
365
366 #[test]
367 fn an_entry_that_cannot_be_built_discards_everything() {
368 let c = config(1_000);
369 let mut cap = Capturing::new(&c);
370 cap.push(entry(0));
371 cap.push(None);
372 cap.push(entry(2));
373 let done = cap.finish("d", false);
374 assert_eq!(done.status, CaptureStatus::LimitExceeded);
375 assert!(done.supplies.is_empty());
376 }
377
378 #[test]
379 fn overflowing_after_a_kept_entry_discards_it_too() {
380 let c = config(15);
381 let mut cap = Capturing::new(&c);
382 cap.push(entry(0));
383 assert_eq!(cap.supplies.len(), 1);
384 cap.push(entry(1));
385 let done = cap.finish("d", false);
386 assert_eq!(done.status, CaptureStatus::LimitExceeded);
387 assert!(done.supplies.is_empty());
388 assert_eq!(done.bytes, 20);
389 let c = config(20);
391 let mut cap = Capturing::new(&c);
392 cap.push(entry(0));
393 cap.push(entry(1));
394 assert_eq!(cap.finish("d", false).status, CaptureStatus::Complete);
395 }
396
397 #[test]
398 fn at_most_4096_entries_are_kept() {
399 let c = config(MAX_CAPTURE_BYTES);
400 let mut cap = Capturing::new(&c);
401 for i in 0..MAX_BUNDLE_ITEMS as u64 {
402 cap.push(entry(i));
403 }
404 assert_eq!(cap.supplies.len(), MAX_BUNDLE_ITEMS);
405 assert!(!cap.exceeded);
406 cap.push(entry(MAX_BUNDLE_ITEMS as u64));
407 let done = cap.finish("d", false);
408 assert_eq!(done.status, CaptureStatus::LimitExceeded);
409 assert!(done.supplies.is_empty());
410 }
411
412 #[test]
413 fn a_cap_overrun_outranks_cancellation() {
414 let c = config(5);
415 let mut cap = Capturing::new(&c);
416 cap.push(entry(0));
417 let done = cap.finish("d", true);
418 assert_eq!(done.status, CaptureStatus::LimitExceeded);
419 assert!(done.supplies.is_empty());
420 let c = config(100);
422 let mut cap = Capturing::new(&c);
423 cap.push(entry(0));
424 let done = cap.finish("d", true);
425 assert_eq!(done.status, CaptureStatus::Cancelled);
426 assert_eq!(done.supplies.len(), 1);
427 }
428}