1use std::{
17 collections::BTreeMap,
18 fmt,
19 sync::{Arc, Mutex, MutexGuard},
20 time::{Duration, Instant},
21};
22
23use serde::{Deserialize, Serialize};
24
25pub const DEFAULT_MAX_TOTAL_REQUESTS: u32 = 32;
27pub const DEFAULT_MAX_WALL_TIME_MS: u64 = 120_000;
29pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 2 * 1024 * 1024;
31pub const DEFAULT_MAX_REQUEST_BODY_BYTES: u64 = 256 * 1024;
33pub const DEFAULT_MAX_ACTIVE_VERIFICATIONS: u16 = 4;
35pub const DEFAULT_MAX_SAME_ACTION_ATTEMPTS: u16 = 3;
37pub const DEFAULT_MAX_CONSECUTIVE_NO_PROGRESS_TURNS: u16 = 4;
39pub const HARD_MAX_TRANSPORT_DISPATCH_RECEIPTS: usize = 4_096;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(default, deny_unknown_fields)]
48pub struct RuntimeBudget {
49 max_total_requests: u32,
50 max_wall_time_ms: u64,
51 max_response_bytes: u64,
52 max_request_body_bytes: u64,
53 max_active_verifications: u16,
54 max_same_action_attempts: u16,
55 max_consecutive_no_progress_turns: u16,
56}
57
58impl RuntimeBudget {
59 pub const fn new(
61 max_total_requests: u32,
62 max_wall_time_ms: u64,
63 max_response_bytes: u64,
64 max_active_verifications: u16,
65 max_same_action_attempts: u16,
66 max_consecutive_no_progress_turns: u16,
67 ) -> Self {
68 Self {
69 max_total_requests,
70 max_wall_time_ms,
71 max_response_bytes,
72 max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES,
73 max_active_verifications,
74 max_same_action_attempts,
75 max_consecutive_no_progress_turns,
76 }
77 }
78
79 pub const fn max_total_requests(self) -> u32 {
81 self.max_total_requests
82 }
83
84 pub const fn max_wall_time(self) -> Duration {
86 Duration::from_millis(self.max_wall_time_ms)
87 }
88
89 pub const fn max_wall_time_ms(self) -> u64 {
91 self.max_wall_time_ms
92 }
93
94 pub const fn max_response_bytes(self) -> u64 {
99 self.max_response_bytes
100 }
101
102 pub const fn max_request_body_bytes(self) -> u64 {
107 self.max_request_body_bytes
108 }
109
110 pub const fn max_active_verifications(self) -> u16 {
112 self.max_active_verifications
113 }
114
115 pub const fn max_same_action_attempts(self) -> u16 {
117 self.max_same_action_attempts
118 }
119
120 pub const fn max_consecutive_no_progress_turns(self) -> u16 {
122 self.max_consecutive_no_progress_turns
123 }
124
125 pub const fn with_max_total_requests(mut self, limit: u32) -> Self {
127 self.max_total_requests = limit;
128 self
129 }
130
131 pub fn with_max_wall_time(mut self, limit: Duration) -> Self {
133 let millis = limit.as_millis();
134 let rounded = if limit.is_zero() { 0 } else { millis.max(1) };
135 self.max_wall_time_ms = u64::try_from(rounded).unwrap_or(u64::MAX);
136 self
137 }
138
139 pub const fn with_max_response_bytes(mut self, limit: u64) -> Self {
141 self.max_response_bytes = limit;
142 self
143 }
144
145 pub const fn with_max_request_body_bytes(mut self, limit: u64) -> Self {
147 self.max_request_body_bytes = limit;
148 self
149 }
150
151 pub const fn with_max_active_verifications(mut self, limit: u16) -> Self {
153 self.max_active_verifications = limit;
154 self
155 }
156
157 pub const fn with_max_same_action_attempts(mut self, limit: u16) -> Self {
159 self.max_same_action_attempts = limit;
160 self
161 }
162
163 pub const fn with_max_consecutive_no_progress_turns(mut self, limit: u16) -> Self {
165 self.max_consecutive_no_progress_turns = limit;
166 self
167 }
168}
169
170impl Default for RuntimeBudget {
171 fn default() -> Self {
172 Self::new(
173 DEFAULT_MAX_TOTAL_REQUESTS,
174 DEFAULT_MAX_WALL_TIME_MS,
175 DEFAULT_MAX_RESPONSE_BYTES,
176 DEFAULT_MAX_ACTIVE_VERIFICATIONS,
177 DEFAULT_MAX_SAME_ACTION_ATTEMPTS,
178 DEFAULT_MAX_CONSECUTIVE_NO_PROGRESS_TURNS,
179 )
180 }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "snake_case")]
186#[non_exhaustive]
187pub enum RuntimeBudgetDimension {
188 TotalRequests,
190 WallTime,
192 ResponseBytes,
195 RequestBodyBytes,
197 ActiveVerifications,
199 SameActionAttempts,
201 ConsecutiveNoProgressTurns,
203}
204
205impl fmt::Display for RuntimeBudgetDimension {
206 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
207 formatter.write_str(match self {
208 Self::TotalRequests => "total_requests",
209 Self::WallTime => "wall_time_ms",
210 Self::ResponseBytes => "response_bytes",
211 Self::RequestBodyBytes => "request_body_bytes",
212 Self::ActiveVerifications => "active_verifications",
213 Self::SameActionAttempts => "same_action_attempts",
214 Self::ConsecutiveNoProgressTurns => "consecutive_no_progress_turns",
215 })
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct RuntimeLimitExceeded {
222 dimension: RuntimeBudgetDimension,
223 limit: u64,
224 observed: u64,
225 action_id: Option<String>,
226}
227
228impl RuntimeLimitExceeded {
229 pub(crate) fn new(
231 dimension: RuntimeBudgetDimension,
232 limit: u64,
233 observed: u64,
234 action_id: Option<String>,
235 ) -> Self {
236 Self {
237 dimension,
238 limit,
239 observed,
240 action_id,
241 }
242 }
243
244 pub const fn dimension(&self) -> RuntimeBudgetDimension {
246 self.dimension
247 }
248
249 pub const fn limit(&self) -> u64 {
251 self.limit
252 }
253
254 pub const fn observed(&self) -> u64 {
256 self.observed
257 }
258
259 pub fn action_id(&self) -> Option<&str> {
261 self.action_id.as_deref()
262 }
263}
264
265impl fmt::Display for RuntimeLimitExceeded {
266 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
267 write!(
268 formatter,
269 "runtime {} limit {} reached by {}",
270 self.dimension, self.limit, self.observed
271 )?;
272 if let Some(action_id) = &self.action_id {
273 write!(formatter, " for action {action_id}")?;
274 }
275 Ok(())
276 }
277}
278
279impl std::error::Error for RuntimeLimitExceeded {}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
283#[serde(rename_all = "snake_case")]
284#[non_exhaustive]
285pub enum TransportDispatchOutcome {
286 Completed,
288 TransportFailure,
290 RequestTimeout,
292 ResponseBudgetReached,
294 Cancelled,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
300pub struct TransportDispatchReceipt {
301 sequence: u64,
302 action_id: String,
303 stage: crate::DecisionExecutionStage,
304 #[serde(skip_serializing_if = "Option::is_none")]
305 origin: Option<crate::DecisionActionOrigin>,
306 request_body_bytes: u64,
307 response_bytes: u64,
308 elapsed_ms: u64,
309 outcome: TransportDispatchOutcome,
310}
311
312impl TransportDispatchReceipt {
313 pub const fn sequence(&self) -> u64 {
315 self.sequence
316 }
317
318 pub fn action_id(&self) -> &str {
320 &self.action_id
321 }
322
323 pub const fn stage(&self) -> crate::DecisionExecutionStage {
325 self.stage
326 }
327
328 pub const fn origin(&self) -> Option<crate::DecisionActionOrigin> {
330 self.origin
331 }
332
333 pub const fn request_body_bytes(&self) -> u64 {
335 self.request_body_bytes
336 }
337
338 pub const fn response_bytes(&self) -> u64 {
340 self.response_bytes
341 }
342
343 pub const fn elapsed_ms(&self) -> u64 {
345 self.elapsed_ms
346 }
347
348 pub const fn outcome(&self) -> TransportDispatchOutcome {
350 self.outcome
351 }
352}
353
354#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
356pub struct TransportDispatchAudit {
357 receipts: Vec<TransportDispatchReceipt>,
358 omitted_receipt_count: u64,
359}
360
361impl TransportDispatchAudit {
362 pub fn receipts(&self) -> &[TransportDispatchReceipt] {
364 &self.receipts
365 }
366
367 pub const fn omitted_receipt_count(&self) -> u64 {
369 self.omitted_receipt_count
370 }
371
372 pub fn is_empty(&self) -> bool {
374 self.receipts.is_empty() && self.omitted_receipt_count == 0
375 }
376}
377
378#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
379pub(crate) struct RequestAccountingSnapshot {
380 total_requests: u32,
381 passive_requests: u32,
382 active_verifications: u16,
383 bootstrap_requests: u32,
384 planned_requests: u32,
385 adaptive_requests: u32,
386 retry_requests: u32,
387 request_body_bytes: u64,
388 response_bytes: u64,
389}
390
391impl RequestAccountingSnapshot {
392 pub(crate) const fn total_requests(self) -> u32 {
393 self.total_requests
394 }
395
396 pub(crate) const fn passive_requests(self) -> u32 {
397 self.passive_requests
398 }
399
400 pub(crate) const fn active_verifications(self) -> u16 {
401 self.active_verifications
402 }
403
404 pub(crate) const fn bootstrap_requests(self) -> u32 {
405 self.bootstrap_requests
406 }
407
408 pub(crate) const fn planned_requests(self) -> u32 {
409 self.planned_requests
410 }
411
412 pub(crate) const fn adaptive_requests(self) -> u32 {
413 self.adaptive_requests
414 }
415
416 pub(crate) const fn retry_requests(self) -> u32 {
417 self.retry_requests
418 }
419
420 pub(crate) const fn request_body_bytes(self) -> u64 {
421 self.request_body_bytes
422 }
423
424 pub(crate) const fn response_bytes(self) -> u64 {
425 self.response_bytes
426 }
427}
428
429#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub(crate) struct RequestAccountingPreflight {
431 remaining_response_bytes: u64,
432}
433
434impl RequestAccountingPreflight {
435 pub(crate) const fn remaining_response_bytes(self) -> u64 {
436 self.remaining_response_bytes
437 }
438}
439
440#[derive(Debug, Default)]
441struct RequestAccountingState {
442 snapshot: RequestAccountingSnapshot,
443 next_dispatch_sequence: u64,
444 dispatch_receipts: Vec<TransportDispatchReceipt>,
445 omitted_dispatch_receipts: u64,
446}
447
448#[derive(Debug, Clone)]
454pub(crate) struct RequestAccountingBroker {
455 budget: RuntimeBudget,
456 state: Arc<Mutex<RequestAccountingState>>,
457 response_read_gate: Arc<tokio::sync::Mutex<()>>,
458}
459
460impl RequestAccountingBroker {
461 pub(crate) fn new(budget: RuntimeBudget) -> Self {
462 Self {
463 budget,
464 state: Arc::new(Mutex::new(RequestAccountingState::default())),
465 response_read_gate: Arc::new(tokio::sync::Mutex::new(())),
466 }
467 }
468
469 #[cfg(test)]
470 pub(crate) const fn budget(&self) -> RuntimeBudget {
471 self.budget
472 }
473
474 pub(crate) fn snapshot(&self) -> RequestAccountingSnapshot {
475 self.lock_state().snapshot
476 }
477
478 pub(crate) fn dispatch_audit(&self) -> TransportDispatchAudit {
479 let state = self.lock_state();
480 let mut receipts = state.dispatch_receipts.clone();
481 receipts.sort_by_key(TransportDispatchReceipt::sequence);
482 TransportDispatchAudit {
483 receipts,
484 omitted_receipt_count: state.omitted_dispatch_receipts,
485 }
486 }
487
488 pub(crate) fn preflight(
489 &self,
490 action_id: &str,
491 stage: crate::DecisionExecutionStage,
492 ) -> Result<RequestAccountingPreflight, RuntimeLimitExceeded> {
493 self.preflight_with_request_body_bytes(action_id, stage, 0)
494 }
495
496 pub(crate) fn preflight_with_request_body_bytes(
497 &self,
498 action_id: &str,
499 stage: crate::DecisionExecutionStage,
500 request_body_bytes: u64,
501 ) -> Result<RequestAccountingPreflight, RuntimeLimitExceeded> {
502 check_request_limits(
503 &self.lock_state().snapshot,
504 self.budget,
505 action_id,
506 stage,
507 request_body_bytes,
508 )
509 }
510
511 #[cfg(test)]
512 pub(crate) fn try_begin(
513 &self,
514 action_id: &str,
515 stage: crate::DecisionExecutionStage,
516 origin: Option<crate::DecisionActionOrigin>,
517 ) -> Result<RequestAccountingLease, RuntimeLimitExceeded> {
518 self.try_begin_with_request_body_bytes(action_id, stage, origin, 0)
519 }
520
521 pub(crate) fn try_begin_with_request_body_bytes(
522 &self,
523 action_id: &str,
524 stage: crate::DecisionExecutionStage,
525 origin: Option<crate::DecisionActionOrigin>,
526 request_body_bytes: u64,
527 ) -> Result<RequestAccountingLease, RuntimeLimitExceeded> {
528 let mut state = self.lock_state();
529 check_request_limits(
530 &state.snapshot,
531 self.budget,
532 action_id,
533 stage,
534 request_body_bytes,
535 )?;
536 record_dispatch(&mut state.snapshot, stage, origin, request_body_bytes);
537 let sequence = state.next_dispatch_sequence;
538 state.next_dispatch_sequence = state.next_dispatch_sequence.saturating_add(1);
539 drop(state);
540
541 Ok(RequestAccountingLease {
542 broker: self.clone(),
543 sequence,
544 action_id: action_id.to_owned(),
545 stage,
546 origin,
547 request_body_bytes,
548 response_bytes: 0,
549 response_budget_reached: false,
550 started_at: Instant::now(),
551 outcome: None,
552 })
553 }
554
555 fn record_dispatch_receipt(&self, receipt: TransportDispatchReceipt) {
556 let mut state = self.lock_state();
557 if state.dispatch_receipts.len() < HARD_MAX_TRANSPORT_DISPATCH_RECEIPTS {
558 state.dispatch_receipts.push(receipt);
559 } else {
560 state.omitted_dispatch_receipts = state.omitted_dispatch_receipts.saturating_add(1);
561 }
562 }
563
564 fn lock_state(&self) -> MutexGuard<'_, RequestAccountingState> {
565 self.state
566 .lock()
567 .unwrap_or_else(std::sync::PoisonError::into_inner)
568 }
569}
570
571#[derive(Debug)]
578pub(crate) struct RequestAccountingLease {
579 broker: RequestAccountingBroker,
580 sequence: u64,
581 action_id: String,
582 stage: crate::DecisionExecutionStage,
583 origin: Option<crate::DecisionActionOrigin>,
584 request_body_bytes: u64,
585 response_bytes: u64,
586 response_budget_reached: bool,
587 started_at: Instant,
588 outcome: Option<TransportDispatchOutcome>,
589}
590
591impl RequestAccountingLease {
592 pub(crate) async fn acquire_response_read(&self) -> tokio::sync::OwnedMutexGuard<()> {
593 Arc::clone(&self.broker.response_read_gate)
594 .lock_owned()
595 .await
596 }
597
598 pub(crate) fn observe_response_bytes(&mut self, bytes: u64) -> u64 {
599 self.response_bytes = self.response_bytes.saturating_add(bytes);
600 let mut state = self.broker.lock_state();
601 let remaining = self
602 .broker
603 .budget
604 .max_response_bytes()
605 .saturating_sub(state.snapshot.response_bytes);
606 if bytes > 0 && bytes >= remaining {
607 self.response_budget_reached = true;
608 }
609 let retained = bytes.min(remaining);
610 state.snapshot.response_bytes = state.snapshot.response_bytes.saturating_add(bytes);
611 retained
612 }
613
614 pub(crate) fn remaining_response_bytes(&self) -> u64 {
615 let response_bytes = self.broker.snapshot().response_bytes;
616 self.broker
617 .budget
618 .max_response_bytes()
619 .saturating_sub(response_bytes)
620 }
621
622 pub(crate) const fn response_budget_reached(&self) -> bool {
623 self.response_budget_reached
624 }
625
626 pub(crate) fn finish(&mut self, outcome: TransportDispatchOutcome) {
627 debug_assert!(self.outcome.is_none(), "dispatch outcome classified twice");
628 self.outcome = Some(outcome);
629 }
630}
631
632impl Drop for RequestAccountingLease {
633 fn drop(&mut self) {
634 let receipt = TransportDispatchReceipt {
635 sequence: self.sequence,
636 action_id: self.action_id.clone(),
637 stage: self.stage,
638 origin: self.origin,
639 request_body_bytes: self.request_body_bytes,
640 response_bytes: self.response_bytes,
641 elapsed_ms: u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
642 outcome: self.outcome.unwrap_or(TransportDispatchOutcome::Cancelled),
643 };
644 self.broker.record_dispatch_receipt(receipt);
645 }
646}
647
648fn check_request_limits(
649 snapshot: &RequestAccountingSnapshot,
650 budget: RuntimeBudget,
651 action_id: &str,
652 stage: crate::DecisionExecutionStage,
653 request_body_bytes: u64,
654) -> Result<RequestAccountingPreflight, RuntimeLimitExceeded> {
655 if snapshot.total_requests >= budget.max_total_requests() {
656 return Err(RuntimeLimitExceeded::new(
657 RuntimeBudgetDimension::TotalRequests,
658 u64::from(budget.max_total_requests()),
659 u64::from(snapshot.total_requests).saturating_add(1),
660 Some(action_id.to_owned()),
661 ));
662 }
663 if snapshot.response_bytes >= budget.max_response_bytes() {
664 return Err(RuntimeLimitExceeded::new(
665 RuntimeBudgetDimension::ResponseBytes,
666 budget.max_response_bytes(),
667 snapshot.response_bytes,
668 Some(action_id.to_owned()),
669 ));
670 }
671 let Some(next_request_body_bytes) = snapshot.request_body_bytes.checked_add(request_body_bytes)
672 else {
673 return Err(RuntimeLimitExceeded::new(
674 RuntimeBudgetDimension::RequestBodyBytes,
675 budget.max_request_body_bytes(),
676 u64::MAX,
677 Some(action_id.to_owned()),
678 ));
679 };
680 if next_request_body_bytes > budget.max_request_body_bytes() {
681 return Err(RuntimeLimitExceeded::new(
682 RuntimeBudgetDimension::RequestBodyBytes,
683 budget.max_request_body_bytes(),
684 next_request_body_bytes,
685 Some(action_id.to_owned()),
686 ));
687 }
688 if stage == crate::DecisionExecutionStage::Active
689 && snapshot.active_verifications >= budget.max_active_verifications()
690 {
691 return Err(RuntimeLimitExceeded::new(
692 RuntimeBudgetDimension::ActiveVerifications,
693 u64::from(budget.max_active_verifications()),
694 u64::from(snapshot.active_verifications).saturating_add(1),
695 Some(action_id.to_owned()),
696 ));
697 }
698
699 Ok(RequestAccountingPreflight {
700 remaining_response_bytes: budget
701 .max_response_bytes()
702 .saturating_sub(snapshot.response_bytes),
703 })
704}
705
706fn record_dispatch(
707 snapshot: &mut RequestAccountingSnapshot,
708 stage: crate::DecisionExecutionStage,
709 origin: Option<crate::DecisionActionOrigin>,
710 request_body_bytes: u64,
711) {
712 snapshot.total_requests = snapshot.total_requests.saturating_add(1);
713 snapshot.request_body_bytes = snapshot
714 .request_body_bytes
715 .saturating_add(request_body_bytes);
716 match stage {
717 crate::DecisionExecutionStage::Passive => {
718 snapshot.passive_requests = snapshot.passive_requests.saturating_add(1);
719 match origin {
720 Some(crate::DecisionActionOrigin::Bootstrap) => {
721 snapshot.bootstrap_requests = snapshot.bootstrap_requests.saturating_add(1);
722 },
723 Some(crate::DecisionActionOrigin::Planned) => {
724 snapshot.planned_requests = snapshot.planned_requests.saturating_add(1);
725 },
726 Some(crate::DecisionActionOrigin::Adaptive) => {
727 snapshot.adaptive_requests = snapshot.adaptive_requests.saturating_add(1);
728 },
729 Some(crate::DecisionActionOrigin::Retry) => {
730 snapshot.retry_requests = snapshot.retry_requests.saturating_add(1);
731 },
732 None => {},
733 }
734 },
735 crate::DecisionExecutionStage::Active => {
736 snapshot.active_verifications = snapshot.active_verifications.saturating_add(1);
737 },
738 }
739}
740
741#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
743pub struct RuntimeUsage {
744 total_requests: u32,
745 passive_requests: u32,
746 active_verifications: u16,
747 bootstrap_requests: u32,
748 planned_requests: u32,
749 adaptive_requests: u32,
750 retry_requests: u32,
751 request_body_bytes: u64,
752 response_bytes: u64,
753 completed_execution_turns: u32,
754 consecutive_no_progress_turns: u16,
755 same_action_attempts: BTreeMap<String, u16>,
756 elapsed_ms: u64,
757}
758
759impl RuntimeUsage {
760 pub const fn total_requests(&self) -> u32 {
762 self.total_requests
763 }
764
765 pub const fn passive_requests(&self) -> u32 {
768 self.passive_requests
769 }
770
771 pub const fn active_verifications(&self) -> u16 {
773 self.active_verifications
774 }
775
776 pub const fn bootstrap_requests(&self) -> u32 {
778 self.bootstrap_requests
779 }
780
781 pub const fn planned_requests(&self) -> u32 {
783 self.planned_requests
784 }
785
786 pub const fn adaptive_requests(&self) -> u32 {
788 self.adaptive_requests
789 }
790
791 pub const fn retry_requests(&self) -> u32 {
793 self.retry_requests
794 }
795
796 pub const fn request_body_bytes(&self) -> u64 {
800 self.request_body_bytes
801 }
802
803 pub const fn response_bytes(&self) -> u64 {
810 self.response_bytes
811 }
812
813 pub const fn completed_execution_turns(&self) -> u32 {
815 self.completed_execution_turns
816 }
817
818 pub const fn consecutive_no_progress_turns(&self) -> u16 {
820 self.consecutive_no_progress_turns
821 }
822
823 pub fn same_action_attempts(&self, action_id: &str) -> u16 {
825 self.same_action_attempts
826 .get(action_id)
827 .copied()
828 .unwrap_or(0)
829 }
830
831 pub fn action_attempts(&self) -> &BTreeMap<String, u16> {
833 &self.same_action_attempts
834 }
835
836 pub fn total_action_attempts(&self) -> u64 {
838 self.same_action_attempts
839 .values()
840 .map(|attempts| u64::from(*attempts))
841 .sum()
842 }
843
844 pub const fn elapsed(&self) -> Duration {
846 Duration::from_millis(self.elapsed_ms)
847 }
848
849 pub const fn elapsed_ms(&self) -> u64 {
851 self.elapsed_ms
852 }
853
854 pub(crate) fn reserve_action_attempt(&mut self, action_id: &str) {
855 let attempts = self
856 .same_action_attempts
857 .entry(action_id.to_owned())
858 .or_default();
859 *attempts = attempts.saturating_add(1);
860 }
861
862 pub(crate) fn sync_request_accounting(&mut self, snapshot: RequestAccountingSnapshot) {
863 self.total_requests = self.total_requests.max(snapshot.total_requests());
867 self.passive_requests = self.passive_requests.max(snapshot.passive_requests());
868 self.active_verifications = self
869 .active_verifications
870 .max(snapshot.active_verifications());
871 self.bootstrap_requests = self.bootstrap_requests.max(snapshot.bootstrap_requests());
872 self.planned_requests = self.planned_requests.max(snapshot.planned_requests());
873 self.adaptive_requests = self.adaptive_requests.max(snapshot.adaptive_requests());
874 self.retry_requests = self.retry_requests.max(snapshot.retry_requests());
875 self.request_body_bytes = self.request_body_bytes.max(snapshot.request_body_bytes());
876 self.response_bytes = self.response_bytes.max(snapshot.response_bytes());
877 }
878
879 pub(crate) fn record_execution_progress(&mut self, progressed: bool) {
880 self.completed_execution_turns = self.completed_execution_turns.saturating_add(1);
881 if progressed {
882 self.consecutive_no_progress_turns = 0;
883 } else {
884 self.consecutive_no_progress_turns =
885 self.consecutive_no_progress_turns.saturating_add(1);
886 }
887 }
888
889 pub(crate) fn set_elapsed(&mut self, elapsed: Duration) {
890 self.elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
891 }
892}
893
894#[cfg(test)]
895mod tests {
896 use std::{
897 sync::{Arc, Barrier},
898 thread,
899 };
900
901 use super::*;
902
903 #[test]
904 fn budget_accepts_fail_closed_zero_values_and_round_trips() {
905 let budget = RuntimeBudget::default()
906 .with_max_total_requests(0)
907 .with_max_wall_time(Duration::ZERO)
908 .with_max_response_bytes(0)
909 .with_max_request_body_bytes(0)
910 .with_max_active_verifications(0)
911 .with_max_same_action_attempts(0)
912 .with_max_consecutive_no_progress_turns(0);
913
914 let encoded = serde_json::to_string(&budget).unwrap();
915 assert_eq!(
916 serde_json::from_str::<RuntimeBudget>(&encoded).unwrap(),
917 budget
918 );
919 assert_eq!(budget.max_wall_time(), Duration::ZERO);
920
921 let sub_millisecond =
922 RuntimeBudget::default().with_max_wall_time(Duration::from_micros(999));
923 assert_eq!(sub_millisecond.max_wall_time(), Duration::from_millis(1));
924
925 let partial: RuntimeBudget = serde_json::from_value(serde_json::json!({
926 "max_total_requests": 7
927 }))
928 .unwrap();
929 assert_eq!(partial.max_total_requests(), 7);
930 assert_eq!(partial.max_response_bytes(), DEFAULT_MAX_RESPONSE_BYTES);
931 assert_eq!(
932 partial.max_request_body_bytes(),
933 DEFAULT_MAX_REQUEST_BODY_BYTES
934 );
935 assert!(serde_json::from_value::<RuntimeBudget>(serde_json::json!({
936 "max_total_requets": 7
937 }))
938 .is_err());
939
940 assert_eq!(
941 serde_json::to_value(RuntimeBudget::default()).unwrap(),
942 serde_json::json!({
943 "max_total_requests": DEFAULT_MAX_TOTAL_REQUESTS,
944 "max_wall_time_ms": DEFAULT_MAX_WALL_TIME_MS,
945 "max_response_bytes": DEFAULT_MAX_RESPONSE_BYTES,
946 "max_request_body_bytes": DEFAULT_MAX_REQUEST_BODY_BYTES,
947 "max_active_verifications": DEFAULT_MAX_ACTIVE_VERIFICATIONS,
948 "max_same_action_attempts": DEFAULT_MAX_SAME_ACTION_ATTEMPTS,
949 "max_consecutive_no_progress_turns": DEFAULT_MAX_CONSECUTIVE_NO_PROGRESS_TURNS
950 })
951 );
952 }
953
954 #[test]
955 fn zero_limits_fail_preflight_without_mutating_accounting() {
956 let no_requests =
957 RequestAccountingBroker::new(RuntimeBudget::default().with_max_total_requests(0));
958 let request_error = no_requests
959 .preflight("http.bootstrap", crate::DecisionExecutionStage::Passive)
960 .unwrap_err();
961 assert_eq!(
962 request_error.dimension(),
963 RuntimeBudgetDimension::TotalRequests
964 );
965 assert_eq!(no_requests.snapshot(), RequestAccountingSnapshot::default());
966
967 let no_bytes =
968 RequestAccountingBroker::new(RuntimeBudget::default().with_max_response_bytes(0));
969 let bytes_error = no_bytes
970 .preflight("http.bootstrap", crate::DecisionExecutionStage::Passive)
971 .unwrap_err();
972 assert_eq!(
973 bytes_error.dimension(),
974 RuntimeBudgetDimension::ResponseBytes
975 );
976 assert_eq!(no_bytes.snapshot(), RequestAccountingSnapshot::default());
977
978 let no_request_body =
979 RequestAccountingBroker::new(RuntimeBudget::default().with_max_request_body_bytes(0));
980 let request_body_error = no_request_body
981 .preflight_with_request_body_bytes(
982 "http.bootstrap",
983 crate::DecisionExecutionStage::Passive,
984 1,
985 )
986 .unwrap_err();
987 assert_eq!(
988 request_body_error.dimension(),
989 RuntimeBudgetDimension::RequestBodyBytes
990 );
991 assert_eq!(
992 no_request_body.snapshot(),
993 RequestAccountingSnapshot::default()
994 );
995
996 let no_active =
997 RequestAccountingBroker::new(RuntimeBudget::default().with_max_active_verifications(0));
998 let active_error = no_active
999 .preflight("http.verify", crate::DecisionExecutionStage::Active)
1000 .unwrap_err();
1001 assert_eq!(
1002 active_error.dimension(),
1003 RuntimeBudgetDimension::ActiveVerifications
1004 );
1005 assert_eq!(no_active.snapshot(), RequestAccountingSnapshot::default());
1006 }
1007
1008 #[test]
1009 fn preflight_reports_remaining_bytes_without_reserving_a_dispatch() {
1010 let broker = RequestAccountingBroker::new(
1011 RuntimeBudget::default()
1012 .with_max_total_requests(1)
1013 .with_max_response_bytes(7),
1014 );
1015
1016 let first = broker
1017 .preflight("http.bootstrap", crate::DecisionExecutionStage::Passive)
1018 .unwrap();
1019 let second = broker
1020 .preflight("http.bootstrap", crate::DecisionExecutionStage::Passive)
1021 .unwrap();
1022
1023 assert_eq!(first.remaining_response_bytes(), 7);
1024 assert_eq!(second, first);
1025 assert_eq!(broker.snapshot(), RequestAccountingSnapshot::default());
1026 assert_eq!(broker.budget().max_total_requests(), 1);
1027 }
1028
1029 #[test]
1030 fn dropped_lease_never_refunds_a_dispatch() {
1031 let broker =
1032 RequestAccountingBroker::new(RuntimeBudget::default().with_max_total_requests(1));
1033 let lease = broker
1034 .try_begin(
1035 "http.bootstrap",
1036 crate::DecisionExecutionStage::Passive,
1037 Some(crate::DecisionActionOrigin::Bootstrap),
1038 )
1039 .unwrap();
1040
1041 drop(lease);
1042
1043 let error = broker
1044 .try_begin(
1045 "http.bootstrap",
1046 crate::DecisionExecutionStage::Passive,
1047 Some(crate::DecisionActionOrigin::Bootstrap),
1048 )
1049 .unwrap_err();
1050 assert_eq!(error.dimension(), RuntimeBudgetDimension::TotalRequests);
1051 let snapshot = broker.snapshot();
1052 assert_eq!(snapshot.total_requests(), 1);
1053 assert_eq!(snapshot.passive_requests(), 1);
1054 assert_eq!(snapshot.bootstrap_requests(), 1);
1055 }
1056
1057 #[test]
1058 fn dispatch_audit_preserves_order_and_completed_lease_metadata() {
1059 let broker = RequestAccountingBroker::new(
1060 RuntimeBudget::default()
1061 .with_max_total_requests(2)
1062 .with_max_request_body_bytes(8)
1063 .with_max_response_bytes(8),
1064 );
1065 let mut first = broker
1066 .try_begin_with_request_body_bytes(
1067 "http.control",
1068 crate::DecisionExecutionStage::Passive,
1069 Some(crate::DecisionActionOrigin::Bootstrap),
1070 3,
1071 )
1072 .unwrap();
1073 let mut second = broker
1074 .try_begin_with_request_body_bytes(
1075 "http.candidate",
1076 crate::DecisionExecutionStage::Active,
1077 None,
1078 5,
1079 )
1080 .unwrap();
1081
1082 assert_eq!(first.observe_response_bytes(2), 2);
1083 assert_eq!(second.observe_response_bytes(4), 4);
1084 first.finish(TransportDispatchOutcome::Completed);
1085 second.finish(TransportDispatchOutcome::Completed);
1086
1087 drop(second);
1089 drop(first);
1090
1091 let audit = broker.dispatch_audit();
1092 assert_eq!(audit.omitted_receipt_count(), 0);
1093 assert!(!audit.is_empty());
1094 let receipts = audit.receipts();
1095 assert_eq!(receipts.len(), 2);
1096
1097 assert_eq!(receipts[0].sequence(), 0);
1098 assert_eq!(receipts[0].action_id(), "http.control");
1099 assert_eq!(receipts[0].stage(), crate::DecisionExecutionStage::Passive);
1100 assert_eq!(
1101 receipts[0].origin(),
1102 Some(crate::DecisionActionOrigin::Bootstrap)
1103 );
1104 assert_eq!(receipts[0].request_body_bytes(), 3);
1105 assert_eq!(receipts[0].response_bytes(), 2);
1106 assert_eq!(receipts[0].outcome(), TransportDispatchOutcome::Completed);
1107
1108 assert_eq!(receipts[1].sequence(), 1);
1109 assert_eq!(receipts[1].action_id(), "http.candidate");
1110 assert_eq!(receipts[1].stage(), crate::DecisionExecutionStage::Active);
1111 assert_eq!(receipts[1].origin(), None);
1112 assert_eq!(receipts[1].request_body_bytes(), 5);
1113 assert_eq!(receipts[1].response_bytes(), 4);
1114 assert_eq!(receipts[1].outcome(), TransportDispatchOutcome::Completed);
1115 }
1116
1117 #[test]
1118 fn unclassified_dispatch_is_audited_as_cancelled() {
1119 let broker =
1120 RequestAccountingBroker::new(RuntimeBudget::default().with_max_total_requests(1));
1121 let mut lease = broker
1122 .try_begin(
1123 "http.cancelled",
1124 crate::DecisionExecutionStage::Passive,
1125 Some(crate::DecisionActionOrigin::Adaptive),
1126 )
1127 .unwrap();
1128 assert_eq!(lease.observe_response_bytes(7), 7);
1129
1130 drop(lease);
1131
1132 let audit = broker.dispatch_audit();
1133 let receipt = audit.receipts().first().unwrap();
1134 assert_eq!(audit.receipts().len(), 1);
1135 assert_eq!(receipt.action_id(), "http.cancelled");
1136 assert_eq!(receipt.response_bytes(), 7);
1137 assert_eq!(receipt.outcome(), TransportDispatchOutcome::Cancelled);
1138 }
1139
1140 #[test]
1141 fn dispatch_audit_retention_is_bounded_and_counts_omissions() {
1142 let dispatch_count = HARD_MAX_TRANSPORT_DISPATCH_RECEIPTS + 1;
1143 let broker =
1144 RequestAccountingBroker::new(RuntimeBudget::default().with_max_total_requests(
1145 u32::try_from(dispatch_count).expect("test dispatch count fits in u32"),
1146 ));
1147
1148 for index in 0..dispatch_count {
1149 let mut lease = broker
1150 .try_begin(
1151 &format!("http.dispatch.{index}"),
1152 crate::DecisionExecutionStage::Passive,
1153 Some(crate::DecisionActionOrigin::Planned),
1154 )
1155 .unwrap();
1156 lease.finish(TransportDispatchOutcome::Completed);
1157 }
1158
1159 let audit = broker.dispatch_audit();
1160 assert_eq!(audit.receipts().len(), HARD_MAX_TRANSPORT_DISPATCH_RECEIPTS);
1161 assert_eq!(audit.omitted_receipt_count(), 1);
1162 assert_eq!(audit.receipts().first().unwrap().sequence(), 0);
1163 assert_eq!(
1164 audit.receipts().last().unwrap().sequence(),
1165 u64::try_from(HARD_MAX_TRANSPORT_DISPATCH_RECEIPTS - 1).unwrap()
1166 );
1167 }
1168
1169 #[test]
1170 fn denied_dispatch_does_not_create_an_audit_receipt() {
1171 let broker =
1172 RequestAccountingBroker::new(RuntimeBudget::default().with_max_total_requests(0));
1173
1174 let denied = broker
1175 .try_begin(
1176 "http.denied",
1177 crate::DecisionExecutionStage::Passive,
1178 Some(crate::DecisionActionOrigin::Planned),
1179 )
1180 .unwrap_err();
1181
1182 assert_eq!(denied.dimension(), RuntimeBudgetDimension::TotalRequests);
1183 assert_eq!(broker.snapshot(), RequestAccountingSnapshot::default());
1184 assert!(broker.dispatch_audit().is_empty());
1185 }
1186
1187 #[test]
1188 fn remaining_one_dispatch_is_acquired_atomically() {
1189 let broker =
1190 RequestAccountingBroker::new(RuntimeBudget::default().with_max_total_requests(1));
1191 let barrier = Arc::new(Barrier::new(3));
1192 let mut workers = Vec::new();
1193 for action_id in ["http.a", "http.b"] {
1194 let broker = broker.clone();
1195 let barrier = barrier.clone();
1196 workers.push(thread::spawn(move || {
1197 barrier.wait();
1198 broker
1199 .try_begin(
1200 action_id,
1201 crate::DecisionExecutionStage::Passive,
1202 Some(crate::DecisionActionOrigin::Planned),
1203 )
1204 .map(drop)
1205 .map_err(|error| error.dimension())
1206 }));
1207 }
1208 barrier.wait();
1209
1210 let results: Vec<_> = workers
1211 .into_iter()
1212 .map(|worker| worker.join().unwrap())
1213 .collect();
1214 assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
1215 assert_eq!(
1216 results
1217 .iter()
1218 .filter(|result| { matches!(result, Err(RuntimeBudgetDimension::TotalRequests)) })
1219 .count(),
1220 1
1221 );
1222 assert_eq!(broker.snapshot().total_requests(), 1);
1223 }
1224
1225 #[test]
1226 fn preflight_is_advisory_but_try_begin_is_atomic() {
1227 let broker =
1228 RequestAccountingBroker::new(RuntimeBudget::default().with_max_total_requests(1));
1229 let barrier = Arc::new(Barrier::new(3));
1230 let mut workers = Vec::new();
1231 for action_id in ["http.a", "http.b"] {
1232 let broker = broker.clone();
1233 let barrier = barrier.clone();
1234 workers.push(thread::spawn(move || {
1235 broker
1236 .preflight(action_id, crate::DecisionExecutionStage::Passive)
1237 .unwrap();
1238 barrier.wait();
1239 broker
1240 .try_begin(
1241 action_id,
1242 crate::DecisionExecutionStage::Passive,
1243 Some(crate::DecisionActionOrigin::Planned),
1244 )
1245 .map(drop)
1246 }));
1247 }
1248 barrier.wait();
1249
1250 let results: Vec<_> = workers
1251 .into_iter()
1252 .map(|worker| worker.join().unwrap())
1253 .collect();
1254 assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
1255 let denied = results
1256 .iter()
1257 .find_map(|result| result.as_ref().err())
1258 .unwrap();
1259 assert_eq!(denied.dimension(), RuntimeBudgetDimension::TotalRequests);
1260 assert_eq!(broker.snapshot().total_requests(), 1);
1261 }
1262
1263 #[test]
1264 fn active_limit_is_atomic_without_blocking_passive_dispatch() {
1265 let broker = RequestAccountingBroker::new(
1266 RuntimeBudget::default()
1267 .with_max_total_requests(3)
1268 .with_max_active_verifications(1),
1269 );
1270 let barrier = Arc::new(Barrier::new(3));
1271 let workers: Vec<_> = ["verify.a", "verify.b"]
1272 .into_iter()
1273 .map(|action_id| {
1274 let broker = broker.clone();
1275 let barrier = barrier.clone();
1276 thread::spawn(move || {
1277 barrier.wait();
1278 broker
1279 .try_begin(action_id, crate::DecisionExecutionStage::Active, None)
1280 .map(drop)
1281 })
1282 })
1283 .collect();
1284 barrier.wait();
1285
1286 let results: Vec<_> = workers
1287 .into_iter()
1288 .map(|worker| worker.join().unwrap())
1289 .collect();
1290 assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
1291 let denied = results
1292 .iter()
1293 .find_map(|result| result.as_ref().err())
1294 .unwrap();
1295 assert_eq!(
1296 denied.dimension(),
1297 RuntimeBudgetDimension::ActiveVerifications
1298 );
1299 drop(
1300 broker
1301 .try_begin(
1302 "http.passive",
1303 crate::DecisionExecutionStage::Passive,
1304 Some(crate::DecisionActionOrigin::Planned),
1305 )
1306 .unwrap(),
1307 );
1308 let snapshot = broker.snapshot();
1309 assert_eq!(snapshot.total_requests(), 2);
1310 assert_eq!(snapshot.active_verifications(), 1);
1311 assert_eq!(snapshot.passive_requests(), 1);
1312 }
1313
1314 #[test]
1315 fn response_byte_observations_bound_retention_and_survive_lease_drop() {
1316 let broker = RequestAccountingBroker::new(
1317 RuntimeBudget::default()
1318 .with_max_total_requests(2)
1319 .with_max_response_bytes(5),
1320 );
1321 let mut lease = broker
1322 .try_begin(
1323 "http.bootstrap",
1324 crate::DecisionExecutionStage::Passive,
1325 Some(crate::DecisionActionOrigin::Bootstrap),
1326 )
1327 .unwrap();
1328
1329 assert_eq!(lease.observe_response_bytes(3), 3);
1330 assert_eq!(lease.remaining_response_bytes(), 2);
1331 assert_eq!(lease.observe_response_bytes(4), 2);
1332 assert_eq!(lease.observe_response_bytes(1), 0);
1333 drop(lease);
1334
1335 assert_eq!(broker.snapshot().response_bytes(), 8);
1336 assert_eq!(broker.dispatch_audit().receipts()[0].response_bytes(), 8);
1337 let error = broker
1338 .preflight("http.next", crate::DecisionExecutionStage::Passive)
1339 .unwrap_err();
1340 assert_eq!(error.dimension(), RuntimeBudgetDimension::ResponseBytes);
1341 }
1342
1343 #[test]
1344 fn request_body_bytes_are_checked_and_charged_atomically_before_dispatch() {
1345 let broker = RequestAccountingBroker::new(
1346 RuntimeBudget::default()
1347 .with_max_total_requests(2)
1348 .with_max_request_body_bytes(5),
1349 );
1350 drop(
1351 broker
1352 .try_begin_with_request_body_bytes(
1353 "http.control",
1354 crate::DecisionExecutionStage::Passive,
1355 Some(crate::DecisionActionOrigin::Planned),
1356 3,
1357 )
1358 .unwrap(),
1359 );
1360
1361 let denied = broker
1362 .try_begin_with_request_body_bytes(
1363 "http.candidate",
1364 crate::DecisionExecutionStage::Active,
1365 None,
1366 3,
1367 )
1368 .unwrap_err();
1369 assert_eq!(denied.dimension(), RuntimeBudgetDimension::RequestBodyBytes);
1370 assert_eq!(denied.limit(), 5);
1371 assert_eq!(denied.observed(), 6);
1372
1373 let snapshot = broker.snapshot();
1374 assert_eq!(snapshot.total_requests(), 1);
1375 assert_eq!(snapshot.request_body_bytes(), 3);
1376 assert_eq!(snapshot.active_verifications(), 0);
1377 }
1378
1379 #[test]
1380 fn request_body_accounting_overflow_fails_closed() {
1381 let broker = RequestAccountingBroker::new(
1382 RuntimeBudget::default()
1383 .with_max_total_requests(2)
1384 .with_max_request_body_bytes(u64::MAX),
1385 );
1386 drop(
1387 broker
1388 .try_begin_with_request_body_bytes(
1389 "http.max-body",
1390 crate::DecisionExecutionStage::Passive,
1391 None,
1392 u64::MAX,
1393 )
1394 .unwrap(),
1395 );
1396
1397 let error = broker
1398 .try_begin_with_request_body_bytes(
1399 "http.overflow",
1400 crate::DecisionExecutionStage::Passive,
1401 None,
1402 1,
1403 )
1404 .unwrap_err();
1405 assert_eq!(error.dimension(), RuntimeBudgetDimension::RequestBodyBytes);
1406 assert_eq!(error.limit(), u64::MAX);
1407 assert_eq!(error.observed(), u64::MAX);
1408 assert_eq!(broker.snapshot().total_requests(), 1);
1409 assert_eq!(broker.snapshot().request_body_bytes(), u64::MAX);
1410 }
1411
1412 #[test]
1413 fn concurrent_request_bodies_cannot_exceed_the_session_limit() {
1414 let broker = RequestAccountingBroker::new(
1415 RuntimeBudget::default()
1416 .with_max_total_requests(2)
1417 .with_max_request_body_bytes(5),
1418 );
1419 let barrier = Arc::new(Barrier::new(3));
1420 let workers: Vec<_> = ["http.a", "http.b"]
1421 .into_iter()
1422 .map(|action_id| {
1423 let broker = broker.clone();
1424 let barrier = Arc::clone(&barrier);
1425 thread::spawn(move || {
1426 barrier.wait();
1427 broker
1428 .try_begin_with_request_body_bytes(
1429 action_id,
1430 crate::DecisionExecutionStage::Passive,
1431 Some(crate::DecisionActionOrigin::Planned),
1432 4,
1433 )
1434 .map(drop)
1435 })
1436 })
1437 .collect();
1438 barrier.wait();
1439
1440 let results: Vec<_> = workers
1441 .into_iter()
1442 .map(|worker| worker.join().unwrap())
1443 .collect();
1444 assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
1445 let denied = results
1446 .iter()
1447 .find_map(|result| result.as_ref().err())
1448 .unwrap();
1449 assert_eq!(denied.dimension(), RuntimeBudgetDimension::RequestBodyBytes);
1450 assert_eq!(broker.snapshot().total_requests(), 1);
1451 assert_eq!(broker.snapshot().request_body_bytes(), 4);
1452 }
1453
1454 #[tokio::test]
1455 async fn response_read_gate_limits_concurrent_collectors_to_one_crossing_chunk() {
1456 let broker = RequestAccountingBroker::new(
1457 RuntimeBudget::default()
1458 .with_max_total_requests(3)
1459 .with_max_response_bytes(5),
1460 );
1461 let leases = ["http.a", "http.b", "http.c"].map(|action_id| {
1462 broker
1463 .try_begin(
1464 action_id,
1465 crate::DecisionExecutionStage::Passive,
1466 Some(crate::DecisionActionOrigin::Planned),
1467 )
1468 .unwrap()
1469 });
1470 async fn read_one(mut lease: RequestAccountingLease) -> u64 {
1471 let _guard = lease.acquire_response_read().await;
1472 if lease.remaining_response_bytes() == 0 {
1473 return 0;
1474 }
1475 lease.observe_response_bytes(4)
1476 }
1477 let [first, second, third] = leases;
1478 let (first, second, third) =
1479 tokio::join!(read_one(first), read_one(second), read_one(third));
1480
1481 let retained = first + second + third;
1482 assert_eq!(retained, 5);
1483 assert_eq!(broker.snapshot().response_bytes(), 8);
1484 }
1485
1486 #[test]
1487 fn usage_sync_preserves_wire_shape_and_separates_action_attempts() {
1488 let broker = RequestAccountingBroker::new(
1489 RuntimeBudget::default()
1490 .with_max_total_requests(5)
1491 .with_max_active_verifications(1),
1492 );
1493 for origin in [
1494 crate::DecisionActionOrigin::Bootstrap,
1495 crate::DecisionActionOrigin::Planned,
1496 crate::DecisionActionOrigin::Adaptive,
1497 crate::DecisionActionOrigin::Retry,
1498 ] {
1499 drop(
1500 broker
1501 .try_begin(
1502 "http.probe",
1503 crate::DecisionExecutionStage::Passive,
1504 Some(origin),
1505 )
1506 .unwrap(),
1507 );
1508 }
1509 drop(
1510 broker
1511 .try_begin("http.probe", crate::DecisionExecutionStage::Active, None)
1512 .unwrap(),
1513 );
1514
1515 let mut usage = RuntimeUsage::default();
1516 usage.reserve_action_attempt("http.probe");
1517 usage.reserve_action_attempt("http.probe");
1518 usage.sync_request_accounting(broker.snapshot());
1519
1520 assert_eq!(usage.total_requests(), 5);
1521 assert_eq!(usage.passive_requests(), 4);
1522 assert_eq!(usage.active_verifications(), 1);
1523 assert_eq!(usage.bootstrap_requests(), 1);
1524 assert_eq!(usage.planned_requests(), 1);
1525 assert_eq!(usage.adaptive_requests(), 1);
1526 assert_eq!(usage.retry_requests(), 1);
1527 assert_eq!(usage.request_body_bytes(), 0);
1528 assert_eq!(usage.same_action_attempts("http.probe"), 2);
1529 assert_eq!(usage.total_action_attempts(), 2);
1530 assert_eq!(
1531 serde_json::to_value(&usage).unwrap(),
1532 serde_json::json!({
1533 "total_requests": 5,
1534 "passive_requests": 4,
1535 "active_verifications": 1,
1536 "bootstrap_requests": 1,
1537 "planned_requests": 1,
1538 "adaptive_requests": 1,
1539 "retry_requests": 1,
1540 "request_body_bytes": 0,
1541 "response_bytes": 0,
1542 "completed_execution_turns": 0,
1543 "consecutive_no_progress_turns": 0,
1544 "same_action_attempts": {"http.probe": 2},
1545 "elapsed_ms": 0
1546 })
1547 );
1548 }
1549
1550 #[test]
1551 fn stale_snapshot_cannot_regress_runtime_usage() {
1552 let old = RequestAccountingSnapshot {
1553 total_requests: 1,
1554 passive_requests: 1,
1555 bootstrap_requests: 1,
1556 ..RequestAccountingSnapshot::default()
1557 };
1558 let new = RequestAccountingSnapshot {
1559 total_requests: 2,
1560 passive_requests: 1,
1561 active_verifications: 1,
1562 bootstrap_requests: 1,
1563 request_body_bytes: 4,
1564 response_bytes: 4,
1565 ..RequestAccountingSnapshot::default()
1566 };
1567 let mut usage = RuntimeUsage::default();
1568
1569 usage.sync_request_accounting(new);
1570 usage.sync_request_accounting(old);
1571
1572 assert_eq!(usage.total_requests(), 2);
1573 assert_eq!(usage.passive_requests(), 1);
1574 assert_eq!(usage.active_verifications(), 1);
1575 assert_eq!(usage.bootstrap_requests(), 1);
1576 assert_eq!(usage.request_body_bytes(), 4);
1577 assert_eq!(usage.response_bytes(), 4);
1578 }
1579}