1#[cfg(not(feature = "native"))]
11use fsqlite_types::sync_primitives::Instant;
12#[cfg(feature = "native")]
13use fsqlite_types::sync_primitives::SystemTime;
14use std::collections::{HashMap, HashSet};
15use std::sync::Mutex;
16#[cfg(not(feature = "native"))]
17use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
18use std::thread;
19use std::time::Duration;
20
21#[cfg(feature = "native")]
22use asupersync::combinator::bulkhead::{
23 Bulkhead as AdmissionBulkhead, BulkheadError as AdmissionBulkheadError,
24 BulkheadMetrics as AdmissionBulkheadMetrics, BulkheadPermit as AdmissionPermit, BulkheadPolicy,
25};
26#[cfg(feature = "native")]
27use asupersync::types::Time as AdmissionTime;
28use blake3::Hasher;
29use fsqlite_error::{FrankenError, Result};
30use fsqlite_types::cx::{Cx, cap};
31use fsqlite_types::{IdempotencyKey, ObjectId, RemoteCap, Saga};
32use tracing::{debug, info, warn};
33
34#[cfg(not(feature = "native"))]
35use crate::Bulkhead as AdmissionBulkhead;
36#[cfg(feature = "native")]
37use crate::available_parallelism_or_one;
38#[cfg(not(feature = "native"))]
39use crate::{
40 BulkheadConfig, BulkheadPermit as AdmissionPermit, OverflowPolicy, available_parallelism_or_one,
41};
42
43const BEAD_ID: &str = "bd-numl";
44const MAX_BALANCED_REMOTE_IN_FLIGHT: usize = 8;
45const REMOTE_EFFECTS_EXECUTOR_NAME: &str = "fsqlite.remote_effects";
46#[allow(dead_code)]
49pub(crate) const TIERED_STORAGE_EXECUTOR_NAME: &str = "fsqlite.tiered_storage";
50#[allow(dead_code)]
51const DEFAULT_TIERED_STORAGE_QUEUE_DEPTH: usize = 1;
52#[allow(dead_code)]
53const DEFAULT_TIERED_STORAGE_QUEUE_TIMEOUT: Duration = Duration::from_millis(250);
54const ADMISSION_POLL_INTERVAL: Duration = Duration::from_millis(1);
55
56pub const REMOTE_IDEMPOTENCY_DOMAIN: &str = "fsqlite:remote:v1";
58
59#[derive(Debug, Clone, PartialEq, Eq, Hash)]
61pub enum ComputationName {
62 SymbolGetRange,
64 SymbolPutBatch,
66 SegmentPut,
68 SegmentStat,
70 Custom(String),
72}
73
74impl ComputationName {
75 #[must_use]
76 pub fn as_str(&self) -> &str {
77 match self {
78 Self::SymbolGetRange => "symbol_get_range",
79 Self::SymbolPutBatch => "symbol_put_batch",
80 Self::SegmentPut => "segment_put",
81 Self::SegmentStat => "segment_stat",
82 Self::Custom(name) => name.as_str(),
83 }
84 }
85
86 #[must_use]
87 fn canonical_tag(&self) -> u8 {
88 match self {
89 Self::SymbolGetRange => 0x01,
90 Self::SymbolPutBatch => 0x02,
91 Self::SegmentPut => 0x03,
92 Self::SegmentStat => 0x04,
93 Self::Custom(_) => 0xFF,
94 }
95 }
96
97 #[must_use]
98 fn canonical_name_bytes(&self) -> Vec<u8> {
99 self.as_str().as_bytes().to_vec()
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct NamedComputation {
106 pub name: ComputationName,
107 pub input_bytes: Vec<u8>,
108}
109
110impl NamedComputation {
111 #[must_use]
112 pub const fn new(name: ComputationName, input_bytes: Vec<u8>) -> Self {
113 Self { name, input_bytes }
114 }
115
116 pub fn canonical_request_bytes(&self) -> Result<Vec<u8>> {
125 let domain = REMOTE_IDEMPOTENCY_DOMAIN.as_bytes();
126 let name_bytes = self.name.canonical_name_bytes();
127
128 let domain_len = u32::try_from(domain.len()).map_err(|_| FrankenError::OutOfRange {
129 what: "remote_domain_len".to_owned(),
130 value: domain.len().to_string(),
131 })?;
132 let name_len = u32::try_from(name_bytes.len()).map_err(|_| FrankenError::OutOfRange {
133 what: "computation_name_len".to_owned(),
134 value: name_bytes.len().to_string(),
135 })?;
136 let input_len =
137 u32::try_from(self.input_bytes.len()).map_err(|_| FrankenError::OutOfRange {
138 what: "computation_input_len".to_owned(),
139 value: self.input_bytes.len().to_string(),
140 })?;
141
142 let mut out = Vec::with_capacity(
143 4 + domain.len() + 1 + 4 + name_bytes.len() + 4 + self.input_bytes.len(),
144 );
145 out.extend_from_slice(&domain_len.to_le_bytes());
146 out.extend_from_slice(domain);
147 out.push(self.name.canonical_tag());
148 out.extend_from_slice(&name_len.to_le_bytes());
149 out.extend_from_slice(&name_bytes);
150 out.extend_from_slice(&input_len.to_le_bytes());
151 out.extend_from_slice(&self.input_bytes);
152 Ok(out)
153 }
154}
155
156#[derive(Debug, Clone)]
158pub struct ComputationRegistry {
159 allowed: HashSet<ComputationName>,
160}
161
162impl ComputationRegistry {
163 #[must_use]
164 pub fn new_empty() -> Self {
165 Self {
166 allowed: HashSet::new(),
167 }
168 }
169
170 #[must_use]
171 pub fn with_normative_names() -> Self {
172 let mut registry = Self::new_empty();
173 registry.register(ComputationName::SymbolGetRange);
174 registry.register(ComputationName::SymbolPutBatch);
175 registry.register(ComputationName::SegmentPut);
176 registry.register(ComputationName::SegmentStat);
177 registry
178 }
179
180 pub fn register(&mut self, name: ComputationName) {
181 self.allowed.insert(name);
182 }
183
184 #[must_use]
185 pub fn is_registered(&self, name: &ComputationName) -> bool {
186 self.allowed.contains(name)
187 }
188
189 pub fn validate(&self, name: &ComputationName) -> Result<()> {
195 if self.is_registered(name) {
196 Ok(())
197 } else {
198 Err(FrankenError::Unsupported)
199 }
200 }
201}
202
203impl Default for ComputationRegistry {
204 fn default() -> Self {
205 Self::with_normative_names()
206 }
207}
208
209#[derive(Debug, Clone, Default, PartialEq, Eq)]
211pub struct TraceContext {
212 pub trace_id: String,
213 pub saga_id: Option<Saga>,
214 pub idempotency_key: Option<IdempotencyKey>,
215 pub attempt: u32,
216 pub ecs_epoch: u64,
217 pub lab_seed: Option<u64>,
218 pub schedule_fingerprint: Option<String>,
219}
220
221#[must_use]
224pub fn derive_idempotency_key(request_bytes: &[u8]) -> IdempotencyKey {
225 let mut hasher = Hasher::new();
226 hasher.update(REMOTE_IDEMPOTENCY_DOMAIN.as_bytes());
227 hasher.update(request_bytes);
228 let digest = hasher.finalize();
229 let mut out = [0_u8; 16];
230 out.copy_from_slice(&digest.as_bytes()[..16]);
231 IdempotencyKey::from_bytes(out)
232}
233
234#[must_use]
235fn request_digest(request_bytes: &[u8]) -> [u8; 32] {
236 let mut hasher = Hasher::new();
237 hasher.update(request_bytes);
238 *hasher.finalize().as_bytes()
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum IdempotencyDecision {
244 StoredNew(Vec<u8>),
245 Replayed(Vec<u8>),
246}
247
248#[derive(Debug, Clone)]
249struct IdempotencyEntry {
250 computation: ComputationName,
251 request_digest: [u8; 32],
252 outcome: Vec<u8>,
253}
254
255#[derive(Debug, Default)]
257pub struct IdempotencyStore {
258 entries: Mutex<HashMap<IdempotencyKey, IdempotencyEntry>>,
259}
260
261impl IdempotencyStore {
262 #[must_use]
263 pub fn new() -> Self {
264 Self::default()
265 }
266
267 pub fn register_or_replay(
274 &self,
275 key: IdempotencyKey,
276 computation: &ComputationName,
277 request_bytes: &[u8],
278 outcome: &[u8],
279 ) -> Result<IdempotencyDecision> {
280 let digest = request_digest(request_bytes);
281 let mut guard = self
282 .entries
283 .lock()
284 .unwrap_or_else(std::sync::PoisonError::into_inner);
285
286 if let Some(existing) = guard.get(&key) {
287 if existing.request_digest == digest && existing.computation == *computation {
288 return Ok(IdempotencyDecision::Replayed(existing.outcome.clone()));
289 }
290 return Err(FrankenError::Internal(
291 "idempotency conflict: same key used for different remote request".to_owned(),
292 ));
293 }
294
295 guard.insert(
296 key,
297 IdempotencyEntry {
298 computation: computation.clone(),
299 request_digest: digest,
300 outcome: outcome.to_vec(),
301 },
302 );
303 drop(guard);
304 Ok(IdempotencyDecision::StoredNew(outcome.to_vec()))
305 }
306}
307
308pub fn require_remote_cap<Caps>(_: &Cx<Caps>, remote_cap: Option<RemoteCap>) -> Result<RemoteCap>
314where
315 Caps: cap::SubsetOf<cap::All> + cap::HasRemote,
316{
317 remote_cap.ok_or_else(|| {
318 FrankenError::Internal("remote capability token missing for remote effect".to_owned())
319 })
320}
321
322#[must_use]
326pub const fn conservative_remote_max_in_flight(parallelism: usize) -> usize {
327 let base = parallelism / 8;
328 if base == 0 {
329 1
330 } else if base > MAX_BALANCED_REMOTE_IN_FLIGHT {
331 MAX_BALANCED_REMOTE_IN_FLIGHT
332 } else {
333 base
334 }
335}
336
337#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
339pub struct AdmissionSnapshot {
340 pub active_permits: usize,
341 pub queue_depth: usize,
342 pub total_rejected: u64,
343 pub total_cancelled: u64,
344}
345
346#[cfg(feature = "native")]
347#[must_use]
348fn admission_now() -> AdmissionTime {
349 let millis = SystemTime::now()
350 .duration_since(SystemTime::UNIX_EPOCH)
351 .unwrap_or_default()
352 .as_millis();
353 let millis = u64::try_from(millis).unwrap_or(u64::MAX);
354 AdmissionTime::from_millis(millis)
355}
356
357#[derive(Debug)]
359pub struct Executor {
360 name: &'static str,
361 max_in_flight: usize,
362 max_queue: usize,
363 queue_timeout: Duration,
364 bulkhead: AdmissionBulkhead,
365 #[cfg(not(feature = "native"))]
366 queued_waiters: AtomicUsize,
367 #[cfg(not(feature = "native"))]
368 total_rejected: AtomicU64,
369 #[cfg(not(feature = "native"))]
370 total_cancelled: AtomicU64,
371}
372
373impl Executor {
374 pub fn from_pragma_remote_max_in_flight(remote_max_in_flight: usize) -> Result<Self> {
383 if remote_max_in_flight == 0 {
384 Ok(Self::balanced_default())
385 } else {
386 Self::with_max_in_flight(remote_max_in_flight)
387 }
388 }
389
390 pub fn with_max_in_flight(max_in_flight: usize) -> Result<Self> {
396 Self::with_limits(
397 REMOTE_EFFECTS_EXECUTOR_NAME,
398 max_in_flight,
399 0,
400 Duration::ZERO,
401 )
402 }
403
404 #[must_use]
405 pub fn balanced_default() -> Self {
406 let p = available_parallelism_or_one();
407 let max_in_flight = conservative_remote_max_in_flight(p);
408 Self::with_max_in_flight(max_in_flight)
409 .expect("remote balanced max_in_flight is always >= 1")
410 }
411
412 #[must_use]
416 #[allow(dead_code)]
417 pub(crate) fn balanced_tiered_storage_default() -> Self {
418 let p = available_parallelism_or_one();
419 let max_in_flight = conservative_remote_max_in_flight(p);
420 Self::with_limits(
421 TIERED_STORAGE_EXECUTOR_NAME,
422 max_in_flight,
423 DEFAULT_TIERED_STORAGE_QUEUE_DEPTH,
424 DEFAULT_TIERED_STORAGE_QUEUE_TIMEOUT,
425 )
426 .expect("tiered storage balanced max_in_flight is always >= 1")
427 }
428
429 pub(crate) fn with_limits(
436 name: &'static str,
437 max_in_flight: usize,
438 max_queue: usize,
439 queue_timeout: Duration,
440 ) -> Result<Self> {
441 let max_in_flight_u32 =
442 u32::try_from(max_in_flight).map_err(|_| FrankenError::OutOfRange {
443 what: format!("{name}.max_in_flight"),
444 value: max_in_flight.to_string(),
445 })?;
446 if max_in_flight_u32 == 0 {
447 return Err(FrankenError::OutOfRange {
448 what: format!("{name}.max_in_flight"),
449 value: max_in_flight.to_string(),
450 });
451 }
452 #[cfg(feature = "native")]
453 let bulkhead = {
454 let max_queue_u32 = u32::try_from(max_queue).map_err(|_| FrankenError::OutOfRange {
455 what: format!("{name}.max_queue"),
456 value: max_queue.to_string(),
457 })?;
458 AdmissionBulkhead::new(BulkheadPolicy {
459 name: name.to_owned(),
460 max_concurrent: max_in_flight_u32,
461 max_queue: max_queue_u32,
462 queue_timeout,
463 weighted: false,
464 on_full: None,
465 })
466 };
467 #[cfg(not(feature = "native"))]
468 let bulkhead = {
469 let config = BulkheadConfig::new(max_in_flight, 0, OverflowPolicy::DropBusy)
470 .ok_or_else(|| FrankenError::OutOfRange {
471 what: format!("{name}.max_in_flight"),
472 value: max_in_flight.to_string(),
473 })?;
474 AdmissionBulkhead::new(config)
475 };
476
477 Ok(Self {
478 name,
479 max_in_flight,
480 max_queue,
481 queue_timeout,
482 bulkhead,
483 #[cfg(not(feature = "native"))]
484 queued_waiters: AtomicUsize::new(0),
485 #[cfg(not(feature = "native"))]
486 total_rejected: AtomicU64::new(0),
487 #[cfg(not(feature = "native"))]
488 total_cancelled: AtomicU64::new(0),
489 })
490 }
491
492 #[must_use]
493 pub const fn name(&self) -> &'static str {
494 self.name
495 }
496
497 #[must_use]
498 pub const fn max_in_flight(&self) -> usize {
499 self.max_in_flight
500 }
501
502 #[must_use]
503 pub const fn max_queue(&self) -> usize {
504 self.max_queue
505 }
506
507 #[must_use]
508 pub fn snapshot(&self) -> AdmissionSnapshot {
509 #[cfg(feature = "native")]
510 {
511 let metrics: AdmissionBulkheadMetrics = self.bulkhead.metrics();
512 AdmissionSnapshot {
513 #[allow(clippy::cast_possible_truncation)]
514 active_permits: metrics.active_permits as usize,
515 #[allow(clippy::cast_possible_truncation)]
516 queue_depth: metrics.queue_depth as usize,
517 total_rejected: metrics.total_rejected,
518 total_cancelled: metrics.total_cancelled,
519 }
520 }
521 #[cfg(not(feature = "native"))]
522 {
523 AdmissionSnapshot {
524 active_permits: self.bulkhead.in_flight(),
525 queue_depth: self.queued_waiters.load(Ordering::Acquire),
526 total_rejected: self.total_rejected.load(Ordering::Acquire),
527 total_cancelled: self.total_cancelled.load(Ordering::Acquire),
528 }
529 }
530 }
531
532 #[cfg(not(feature = "native"))]
533 fn reserve_fallback_waiter(&self) -> bool {
534 loop {
535 let current = self.queued_waiters.load(Ordering::Acquire);
536 if current >= self.max_queue {
537 return false;
538 }
539 let next = current + 1;
540 if self
541 .queued_waiters
542 .compare_exchange_weak(current, next, Ordering::AcqRel, Ordering::Acquire)
543 .is_ok()
544 {
545 return true;
546 }
547 }
548 }
549
550 #[cfg(test)]
551 #[allow(dead_code)]
552 pub(crate) fn try_acquire_for_testing(&self) -> Option<AdmissionPermit<'_>> {
553 #[cfg(feature = "native")]
554 {
555 self.bulkhead.try_acquire(1, admission_now())
556 }
557 #[cfg(not(feature = "native"))]
558 {
559 self.bulkhead.try_acquire().ok()
560 }
561 }
562
563 pub(crate) fn run<Caps, T, F>(
564 &self,
565 cx: &Cx<Caps>,
566 effect_name: &str,
567 saga: Option<Saga>,
568 idempotency_key: Option<IdempotencyKey>,
569 ecs_epoch: u64,
570 operation: F,
571 ) -> Result<T>
572 where
573 Caps: cap::SubsetOf<cap::All>,
574 F: FnOnce() -> Result<T>,
575 {
576 let _permit = self.acquire(cx, effect_name, saga, idempotency_key, ecs_epoch)?;
577 cx.checkpoint().map_err(|_| {
578 let snapshot = self.snapshot();
579 warn!(
580 bead_id = BEAD_ID,
581 executor = self.name,
582 effect_name,
583 saga_id = format_saga(saga),
584 idempotency_key = format_key(idempotency_key),
585 ecs_epoch,
586 admission = "cancelled_post_acquire",
587 active_permits = snapshot.active_permits,
588 queue_depth = snapshot.queue_depth,
589 total_cancelled = snapshot.total_cancelled,
590 "remote operation cancelled after admission and before dispatch"
591 );
592 FrankenError::Busy
593 })?;
594 match operation() {
595 Ok(out) => {
596 let snapshot = self.snapshot();
597 info!(
598 bead_id = BEAD_ID,
599 executor = self.name,
600 effect_name,
601 saga_id = format_saga(saga),
602 idempotency_key = format_key(idempotency_key),
603 ecs_epoch,
604 active_permits = snapshot.active_permits,
605 queue_depth = snapshot.queue_depth,
606 "remote operation completed under admission control"
607 );
608 Ok(out)
609 }
610 Err(err) => {
611 let snapshot = self.snapshot();
612 warn!(
613 bead_id = BEAD_ID,
614 executor = self.name,
615 effect_name,
616 saga_id = format_saga(saga),
617 idempotency_key = format_key(idempotency_key),
618 ecs_epoch,
619 active_permits = snapshot.active_permits,
620 queue_depth = snapshot.queue_depth,
621 error = %err,
622 "remote operation failed under admission control"
623 );
624 Err(err)
625 }
626 }
627 }
628
629 fn acquire<Caps>(
630 &self,
631 cx: &Cx<Caps>,
632 effect_name: &str,
633 saga: Option<Saga>,
634 idempotency_key: Option<IdempotencyKey>,
635 ecs_epoch: u64,
636 ) -> Result<AdmissionPermit<'_>>
637 where
638 Caps: cap::SubsetOf<cap::All>,
639 {
640 cx.checkpoint().map_err(|_| FrankenError::Busy)?;
641
642 #[cfg(feature = "native")]
643 {
644 if let Some(permit) = self.bulkhead.try_acquire(1, admission_now()) {
645 let snapshot = self.snapshot();
646 debug!(
647 bead_id = BEAD_ID,
648 executor = self.name,
649 effect_name,
650 saga_id = format_saga(saga),
651 idempotency_key = format_key(idempotency_key),
652 ecs_epoch,
653 admission = "immediate",
654 active_permits = snapshot.active_permits,
655 queue_depth = snapshot.queue_depth,
656 max_in_flight = self.max_in_flight,
657 max_queue = self.max_queue,
658 "remote admission granted immediately"
659 );
660 return Ok(permit);
661 }
662
663 let snapshot = self.snapshot();
664 if self.max_queue == 0 {
665 warn!(
666 bead_id = BEAD_ID,
667 executor = self.name,
668 effect_name,
669 saga_id = format_saga(saga),
670 idempotency_key = format_key(idempotency_key),
671 ecs_epoch,
672 admission = "rejected",
673 active_permits = snapshot.active_permits,
674 queue_depth = snapshot.queue_depth,
675 total_rejected = snapshot.total_rejected,
676 "remote admission saturated"
677 );
678 return Err(FrankenError::Busy);
679 }
680
681 let queued_at = admission_now();
682 let entry_id = match self.bulkhead.enqueue(1, queued_at) {
683 Ok(entry_id) => entry_id,
684 Err(AdmissionBulkheadError::Full | AdmissionBulkheadError::QueueFull) => {
685 let snapshot = self.snapshot();
686 warn!(
687 bead_id = BEAD_ID,
688 executor = self.name,
689 effect_name,
690 saga_id = format_saga(saga),
691 idempotency_key = format_key(idempotency_key),
692 ecs_epoch,
693 admission = "rejected",
694 active_permits = snapshot.active_permits,
695 queue_depth = snapshot.queue_depth,
696 total_rejected = snapshot.total_rejected,
697 "remote admission queue full"
698 );
699 return Err(FrankenError::Busy);
700 }
701 Err(
702 AdmissionBulkheadError::QueueTimeout { .. } | AdmissionBulkheadError::Cancelled,
703 ) => {
704 return Err(FrankenError::Busy);
705 }
706 Err(AdmissionBulkheadError::Inner(())) => unreachable!(),
707 };
708
709 let snapshot = self.snapshot();
710 debug!(
711 bead_id = BEAD_ID,
712 executor = self.name,
713 effect_name,
714 saga_id = format_saga(saga),
715 idempotency_key = format_key(idempotency_key),
716 ecs_epoch,
717 admission = "queued",
718 entry_id,
719 queue_depth = snapshot.queue_depth,
720 queue_timeout_ms = self.queue_timeout.as_millis(),
721 "remote admission queued"
722 );
723
724 loop {
725 if cx.checkpoint().is_err() {
726 self.bulkhead.cancel_entry(entry_id, AdmissionTime::ZERO);
727 let snapshot = self.snapshot();
728 warn!(
729 bead_id = BEAD_ID,
730 executor = self.name,
731 effect_name,
732 saga_id = format_saga(saga),
733 idempotency_key = format_key(idempotency_key),
734 ecs_epoch,
735 admission = "cancelled",
736 entry_id,
737 queue_depth = snapshot.queue_depth,
738 total_cancelled = snapshot.total_cancelled,
739 "remote admission cancelled while waiting"
740 );
741 return Err(FrankenError::Busy);
742 }
743
744 let now = admission_now();
745 match self.bulkhead.check_entry(entry_id, now) {
746 Ok(Some(permit)) => {
747 let waited_ms = now.as_millis().saturating_sub(queued_at.as_millis());
748 let snapshot = self.snapshot();
749 debug!(
750 bead_id = BEAD_ID,
751 executor = self.name,
752 effect_name,
753 saga_id = format_saga(saga),
754 idempotency_key = format_key(idempotency_key),
755 ecs_epoch,
756 admission = "dequeued",
757 entry_id,
758 waited_ms,
759 active_permits = snapshot.active_permits,
760 queue_depth = snapshot.queue_depth,
761 "remote admission granted from queue"
762 );
763 return Ok(permit);
764 }
765 Ok(None) => thread::sleep(ADMISSION_POLL_INTERVAL),
766 Err(AdmissionBulkheadError::QueueTimeout { waited }) => {
767 let snapshot = self.snapshot();
768 warn!(
769 bead_id = BEAD_ID,
770 executor = self.name,
771 effect_name,
772 saga_id = format_saga(saga),
773 idempotency_key = format_key(idempotency_key),
774 ecs_epoch,
775 admission = "timed_out",
776 entry_id,
777 waited_ms = waited.as_millis(),
778 queue_depth = snapshot.queue_depth,
779 total_rejected = snapshot.total_rejected,
780 "remote admission queue timeout"
781 );
782 return Err(FrankenError::Busy);
783 }
784 Err(
785 AdmissionBulkheadError::Cancelled
786 | AdmissionBulkheadError::Full
787 | AdmissionBulkheadError::QueueFull,
788 ) => {
789 return Err(FrankenError::Busy);
790 }
791 Err(AdmissionBulkheadError::Inner(())) => unreachable!(),
792 }
793 }
794 }
795
796 #[cfg(not(feature = "native"))]
797 {
798 if let Ok(permit) = self.bulkhead.try_acquire() {
799 let snapshot = self.snapshot();
800 debug!(
801 bead_id = BEAD_ID,
802 executor = self.name,
803 effect_name,
804 saga_id = format_saga(saga),
805 idempotency_key = format_key(idempotency_key),
806 ecs_epoch,
807 admission = "immediate",
808 active_permits = snapshot.active_permits,
809 queue_depth = snapshot.queue_depth,
810 max_in_flight = self.max_in_flight,
811 max_queue = self.max_queue,
812 "remote admission granted via local fallback bulkhead"
813 );
814 return Ok(permit);
815 }
816
817 if self.max_queue == 0 {
818 self.total_rejected.fetch_add(1, Ordering::AcqRel);
819 let snapshot = self.snapshot();
820 warn!(
821 bead_id = BEAD_ID,
822 executor = self.name,
823 effect_name,
824 saga_id = format_saga(saga),
825 idempotency_key = format_key(idempotency_key),
826 ecs_epoch,
827 admission = "rejected",
828 active_permits = snapshot.active_permits,
829 queue_depth = snapshot.queue_depth,
830 total_rejected = snapshot.total_rejected,
831 "remote admission saturated"
832 );
833 return Err(FrankenError::Busy);
834 }
835
836 if !self.reserve_fallback_waiter() {
837 self.total_rejected.fetch_add(1, Ordering::AcqRel);
838 let snapshot = self.snapshot();
839 warn!(
840 bead_id = BEAD_ID,
841 executor = self.name,
842 effect_name,
843 saga_id = format_saga(saga),
844 idempotency_key = format_key(idempotency_key),
845 ecs_epoch,
846 admission = "rejected",
847 active_permits = snapshot.active_permits,
848 queue_depth = snapshot.queue_depth,
849 total_rejected = snapshot.total_rejected,
850 "remote admission queue full"
851 );
852 return Err(FrankenError::Busy);
853 }
854
855 let queued_at = Instant::now();
856 let queued = FallbackQueueReservation::new(&self.queued_waiters);
857 let snapshot = self.snapshot();
858 debug!(
859 bead_id = BEAD_ID,
860 executor = self.name,
861 effect_name,
862 saga_id = format_saga(saga),
863 idempotency_key = format_key(idempotency_key),
864 ecs_epoch,
865 admission = "queued",
866 queue_depth = snapshot.queue_depth,
867 queue_timeout_ms = self.queue_timeout.as_millis(),
868 "remote admission queued via local fallback bulkhead"
869 );
870
871 loop {
872 if cx.checkpoint().is_err() {
873 self.total_cancelled.fetch_add(1, Ordering::AcqRel);
874 queued.release();
875 let snapshot = self.snapshot();
876 warn!(
877 bead_id = BEAD_ID,
878 executor = self.name,
879 effect_name,
880 saga_id = format_saga(saga),
881 idempotency_key = format_key(idempotency_key),
882 ecs_epoch,
883 admission = "cancelled",
884 queue_depth = snapshot.queue_depth,
885 total_cancelled = snapshot.total_cancelled,
886 "remote admission cancelled while waiting"
887 );
888 return Err(FrankenError::Busy);
889 }
890
891 match self.bulkhead.try_acquire() {
892 Ok(permit) => {
893 let waited_ms =
894 u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX);
895 queued.release();
896 let snapshot = self.snapshot();
897 debug!(
898 bead_id = BEAD_ID,
899 executor = self.name,
900 effect_name,
901 saga_id = format_saga(saga),
902 idempotency_key = format_key(idempotency_key),
903 ecs_epoch,
904 admission = "dequeued",
905 waited_ms,
906 active_permits = snapshot.active_permits,
907 queue_depth = snapshot.queue_depth,
908 "remote admission granted from local fallback queue"
909 );
910 return Ok(permit);
911 }
912 Err(FrankenError::Busy) => {
913 if queued_at.elapsed() >= self.queue_timeout {
914 self.total_rejected.fetch_add(1, Ordering::AcqRel);
915 let waited_ms =
916 u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX);
917 queued.release();
918 let snapshot = self.snapshot();
919 warn!(
920 bead_id = BEAD_ID,
921 executor = self.name,
922 effect_name,
923 saga_id = format_saga(saga),
924 idempotency_key = format_key(idempotency_key),
925 ecs_epoch,
926 admission = "timed_out",
927 waited_ms,
928 queue_depth = snapshot.queue_depth,
929 total_rejected = snapshot.total_rejected,
930 "remote admission queue timeout"
931 );
932 return Err(FrankenError::Busy);
933 }
934 thread::sleep(ADMISSION_POLL_INTERVAL);
935 }
936 Err(err) => return Err(err),
937 }
938 }
939 }
940 }
941
942 pub fn execute<Caps, F>(
952 &self,
953 cx: &Cx<Caps>,
954 remote_cap: Option<RemoteCap>,
955 registry: &ComputationRegistry,
956 computation: &NamedComputation,
957 trace: &TraceContext,
958 operation: F,
959 ) -> Result<Vec<u8>>
960 where
961 Caps: cap::SubsetOf<cap::All> + cap::HasRemote,
962 F: FnOnce() -> Result<Vec<u8>>,
963 {
964 let _cap = require_remote_cap(cx, remote_cap)?;
965 registry.validate(&computation.name)?;
966 debug!(
967 bead_id = BEAD_ID,
968 trace_id = trace.trace_id,
969 effect_name = computation.name.as_str(),
970 saga_id = format_saga(trace.saga_id),
971 idempotency_key = format_key(trace.idempotency_key),
972 attempt = trace.attempt,
973 ecs_epoch = trace.ecs_epoch,
974 lab_seed = ?trace.lab_seed,
975 schedule_fingerprint = ?trace.schedule_fingerprint,
976 "dispatching named remote computation"
977 );
978 let out = self.run(
979 cx,
980 computation.name.as_str(),
981 trace.saga_id,
982 trace.idempotency_key,
983 trace.ecs_epoch,
984 operation,
985 )?;
986
987 info!(
988 bead_id = BEAD_ID,
989 trace_id = trace.trace_id,
990 effect_name = computation.name.as_str(),
991 saga_id = format_saga(trace.saga_id),
992 idempotency_key = format_key(trace.idempotency_key),
993 attempt = trace.attempt,
994 ecs_epoch = trace.ecs_epoch,
995 "remote computation completed"
996 );
997
998 Ok(out)
999 }
1000}
1001
1002#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1004pub enum LeaseEscalation {
1005 Cancel,
1006 Retry,
1007 Fail,
1008}
1009
1010#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1012pub enum LeaseStatus {
1013 Live,
1014 Expired { escalation: LeaseEscalation },
1015}
1016
1017#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1019pub struct LeaseBackedHandle {
1020 pub lease_id: u64,
1021 pub issued_at_millis: u64,
1022 pub ttl_millis: u64,
1023 pub escalation: LeaseEscalation,
1024}
1025
1026impl LeaseBackedHandle {
1027 pub fn new(
1033 lease_id: u64,
1034 issued_at_millis: u64,
1035 ttl_millis: u64,
1036 escalation: LeaseEscalation,
1037 ) -> Result<Self> {
1038 if ttl_millis == 0 {
1039 return Err(FrankenError::OutOfRange {
1040 what: "lease_ttl_millis".to_owned(),
1041 value: "0".to_owned(),
1042 });
1043 }
1044 Ok(Self {
1045 lease_id,
1046 issued_at_millis,
1047 ttl_millis,
1048 escalation,
1049 })
1050 }
1051
1052 #[must_use]
1053 pub fn evaluate(&self, now_millis: u64) -> LeaseStatus {
1054 let age_millis = now_millis.saturating_sub(self.issued_at_millis);
1055 if age_millis >= self.ttl_millis {
1056 LeaseStatus::Expired {
1057 escalation: self.escalation,
1058 }
1059 } else {
1060 LeaseStatus::Live
1061 }
1062 }
1063
1064 pub fn enforce(&self, now_millis: u64, trace: &TraceContext) -> Result<()> {
1073 match self.evaluate(now_millis) {
1074 LeaseStatus::Live => Ok(()),
1075 LeaseStatus::Expired { escalation } => {
1076 warn!(
1077 bead_id = BEAD_ID,
1078 trace_id = trace.trace_id,
1079 lease_id = self.lease_id,
1080 effect_name = "lease_expiry",
1081 saga_id = format_saga(trace.saga_id),
1082 idempotency_key = format_key(trace.idempotency_key),
1083 attempt = trace.attempt,
1084 ecs_epoch = trace.ecs_epoch,
1085 escalation = ?escalation,
1086 "remote lease expired; escalating"
1087 );
1088 match escalation {
1089 LeaseEscalation::Cancel => Err(FrankenError::Busy),
1090 LeaseEscalation::Retry => Err(FrankenError::BusyRecovery),
1091 LeaseEscalation::Fail => Err(FrankenError::LockFailed {
1092 detail: "remote lease expired".to_owned(),
1093 }),
1094 }
1095 }
1096 }
1097 }
1098}
1099
1100#[derive(Debug, Default)]
1102pub struct InMemoryRemoteStore {
1103 segments: HashMap<ObjectId, Vec<u8>>,
1104 uploads: HashMap<IdempotencyKey, UploadRecord>,
1105 upload_count: HashMap<ObjectId, u64>,
1106}
1107
1108#[derive(Debug, Clone)]
1109struct UploadRecord {
1110 segment_id: ObjectId,
1111 payload_digest: [u8; 32],
1112}
1113
1114impl InMemoryRemoteStore {
1115 #[must_use]
1116 pub fn new() -> Self {
1117 Self::default()
1118 }
1119
1120 pub fn put_segment(
1127 &mut self,
1128 segment_id: ObjectId,
1129 payload: &[u8],
1130 key: IdempotencyKey,
1131 ) -> Result<()> {
1132 let digest = request_digest(payload);
1133 if let Some(existing) = self.uploads.get(&key) {
1134 if existing.segment_id == segment_id && existing.payload_digest == digest {
1135 self.segments
1138 .entry(segment_id)
1139 .or_insert_with(|| payload.to_vec());
1140 return Ok(());
1141 }
1142 return Err(FrankenError::Internal(
1143 "remote put conflict: idempotency key reused with different payload".to_owned(),
1144 ));
1145 }
1146
1147 self.uploads.insert(
1148 key,
1149 UploadRecord {
1150 segment_id,
1151 payload_digest: digest,
1152 },
1153 );
1154 self.segments.insert(segment_id, payload.to_vec());
1155 *self.upload_count.entry(segment_id).or_insert(0) += 1;
1156 Ok(())
1157 }
1158
1159 #[must_use]
1160 pub fn has_segment(&self, segment_id: ObjectId) -> bool {
1161 self.segments.contains_key(&segment_id)
1162 }
1163
1164 #[must_use]
1165 pub fn upload_count(&self, segment_id: ObjectId) -> u64 {
1166 *self.upload_count.get(&segment_id).unwrap_or(&0)
1167 }
1168
1169 pub fn remove_segment(&mut self, segment_id: ObjectId) -> bool {
1170 self.segments.remove(&segment_id).is_some()
1171 }
1172}
1173
1174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1176pub enum EvictionPhase {
1177 Init,
1178 Uploaded,
1179 Verified,
1180 Retired,
1181 Cancelled,
1182}
1183
1184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1186pub enum LocalSegmentState {
1187 Present,
1188 Retired,
1189}
1190
1191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1193pub enum EvictionCompensation {
1194 LocalRetained,
1195 RollbackRequired,
1196}
1197
1198#[derive(Debug)]
1200pub struct EvictionSaga {
1201 saga: Saga,
1202 segment_id: ObjectId,
1203 phase: EvictionPhase,
1204 local_state: LocalSegmentState,
1205 upload_idempotency_key: IdempotencyKey,
1206}
1207
1208impl EvictionSaga {
1209 #[must_use]
1210 pub fn new(saga: Saga, segment_id: ObjectId) -> Self {
1211 Self {
1212 upload_idempotency_key: derive_step_key(saga.key(), segment_id, b"segment_put"),
1213 saga,
1214 segment_id,
1215 phase: EvictionPhase::Init,
1216 local_state: LocalSegmentState::Present,
1217 }
1218 }
1219
1220 #[must_use]
1221 pub const fn phase(&self) -> EvictionPhase {
1222 self.phase
1223 }
1224
1225 #[must_use]
1226 pub const fn local_state(&self) -> LocalSegmentState {
1227 self.local_state
1228 }
1229
1230 #[must_use]
1231 pub const fn upload_idempotency_key(&self) -> IdempotencyKey {
1232 self.upload_idempotency_key
1233 }
1234
1235 pub fn upload(&mut self, remote: &mut InMemoryRemoteStore, bytes: &[u8]) -> Result<()> {
1242 if !matches!(self.phase, EvictionPhase::Init | EvictionPhase::Cancelled) {
1243 return Err(FrankenError::Internal(format!(
1244 "eviction upload invalid in phase {:?}",
1245 self.phase
1246 )));
1247 }
1248 remote.put_segment(self.segment_id, bytes, self.upload_idempotency_key)?;
1249 self.phase = EvictionPhase::Uploaded;
1250 Ok(())
1251 }
1252
1253 pub fn verify_remote(&mut self, remote: &InMemoryRemoteStore) -> Result<()> {
1260 if self.phase != EvictionPhase::Uploaded {
1261 return Err(FrankenError::Internal(format!(
1262 "eviction verify invalid in phase {:?}",
1263 self.phase
1264 )));
1265 }
1266 if !remote.has_segment(self.segment_id) {
1267 return Err(FrankenError::Internal(
1268 "segment verification failed: missing in remote store".to_owned(),
1269 ));
1270 }
1271 self.phase = EvictionPhase::Verified;
1272 Ok(())
1273 }
1274
1275 pub fn retire_local(&mut self) -> Result<()> {
1281 if self.phase != EvictionPhase::Verified {
1282 return Err(FrankenError::Internal(format!(
1283 "eviction retire invalid in phase {:?}",
1284 self.phase
1285 )));
1286 }
1287 self.local_state = LocalSegmentState::Retired;
1288 self.phase = EvictionPhase::Retired;
1289 Ok(())
1290 }
1291
1292 #[must_use]
1294 pub fn cancel(&mut self) -> EvictionCompensation {
1295 if self.phase == EvictionPhase::Retired {
1296 EvictionCompensation::RollbackRequired
1297 } else {
1298 self.phase = EvictionPhase::Cancelled;
1299 self.local_state = LocalSegmentState::Present;
1300 debug!(
1301 bead_id = BEAD_ID,
1302 saga_id = format_key(Some(self.saga.key())),
1303 "eviction saga cancelled; local segment retained"
1304 );
1305 EvictionCompensation::LocalRetained
1306 }
1307 }
1308}
1309
1310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1312pub enum CompactionPhase {
1313 Init,
1314 SegmentsStaged,
1315 Published,
1316 LocatorUpdated,
1317 Cancelled,
1318}
1319
1320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1322pub enum CompactionCompensation {
1323 RemoteCleaned,
1324 RollbackRequired,
1325}
1326
1327#[derive(Debug)]
1329pub struct CompactionPublishSaga {
1330 saga: Saga,
1331 manifest_id: ObjectId,
1332 staged_segments: Vec<ObjectId>,
1333 phase: CompactionPhase,
1334 locator_updated: bool,
1335}
1336
1337impl CompactionPublishSaga {
1338 #[must_use]
1339 pub fn new(saga: Saga, manifest_id: ObjectId) -> Self {
1340 Self {
1341 saga,
1342 manifest_id,
1343 staged_segments: Vec::new(),
1344 phase: CompactionPhase::Init,
1345 locator_updated: false,
1346 }
1347 }
1348
1349 #[must_use]
1350 pub const fn phase(&self) -> CompactionPhase {
1351 self.phase
1352 }
1353
1354 #[must_use]
1355 pub const fn locator_updated(&self) -> bool {
1356 self.locator_updated
1357 }
1358
1359 pub fn stage_segments(
1366 &mut self,
1367 remote: &mut InMemoryRemoteStore,
1368 segments: &[(ObjectId, Vec<u8>)],
1369 ) -> Result<()> {
1370 if !matches!(
1371 self.phase,
1372 CompactionPhase::Init | CompactionPhase::Cancelled
1373 ) {
1374 return Err(FrankenError::Internal(format!(
1375 "compaction stage invalid in phase {:?}",
1376 self.phase
1377 )));
1378 }
1379 if segments.is_empty() {
1380 return Err(FrankenError::OutOfRange {
1381 what: "compaction_segments".to_owned(),
1382 value: "0".to_owned(),
1383 });
1384 }
1385
1386 self.staged_segments.clear();
1387 for (segment_id, payload) in segments {
1388 let key = derive_step_key(self.saga.key(), *segment_id, b"compaction_segment_put");
1389 remote.put_segment(*segment_id, payload, key)?;
1390 self.staged_segments.push(*segment_id);
1391 }
1392 self.phase = CompactionPhase::SegmentsStaged;
1393 self.locator_updated = false;
1394 Ok(())
1395 }
1396
1397 pub fn publish_manifest(
1403 &mut self,
1404 remote: &mut InMemoryRemoteStore,
1405 manifest: &[u8],
1406 ) -> Result<()> {
1407 if self.phase != CompactionPhase::SegmentsStaged {
1408 return Err(FrankenError::Internal(format!(
1409 "compaction publish invalid in phase {:?}",
1410 self.phase
1411 )));
1412 }
1413 let key = derive_step_key(
1414 self.saga.key(),
1415 self.manifest_id,
1416 b"compaction_manifest_publish",
1417 );
1418 remote.put_segment(self.manifest_id, manifest, key)?;
1419 self.phase = CompactionPhase::Published;
1420 Ok(())
1421 }
1422
1423 pub fn update_locator(&mut self) -> Result<()> {
1429 if self.phase != CompactionPhase::Published {
1430 return Err(FrankenError::Internal(format!(
1431 "compaction locator update invalid in phase {:?}",
1432 self.phase
1433 )));
1434 }
1435 self.locator_updated = true;
1436 self.phase = CompactionPhase::LocatorUpdated;
1437 Ok(())
1438 }
1439
1440 #[must_use]
1445 pub fn cancel(&mut self, remote: &mut InMemoryRemoteStore) -> CompactionCompensation {
1446 match self.phase {
1447 CompactionPhase::LocatorUpdated => CompactionCompensation::RollbackRequired,
1448 CompactionPhase::Init | CompactionPhase::Cancelled => {
1449 self.phase = CompactionPhase::Cancelled;
1450 self.locator_updated = false;
1451 CompactionCompensation::RemoteCleaned
1452 }
1453 CompactionPhase::SegmentsStaged | CompactionPhase::Published => {
1454 for segment in &self.staged_segments {
1455 let _ = remote.remove_segment(*segment);
1456 }
1457 let _ = remote.remove_segment(self.manifest_id);
1458 self.phase = CompactionPhase::Cancelled;
1459 self.locator_updated = false;
1460 debug!(
1461 bead_id = BEAD_ID,
1462 saga_id = format_key(Some(self.saga.key())),
1463 "compaction saga cancelled; remote staged objects cleaned"
1464 );
1465 CompactionCompensation::RemoteCleaned
1466 }
1467 }
1468 }
1469}
1470
1471#[must_use]
1472fn derive_step_key(
1473 base_key: IdempotencyKey,
1474 object_id: ObjectId,
1475 step_tag: &[u8],
1476) -> IdempotencyKey {
1477 let mut bytes = Vec::with_capacity(16 + 16 + step_tag.len());
1478 bytes.extend_from_slice(base_key.as_bytes());
1479 bytes.extend_from_slice(object_id.as_bytes());
1480 bytes.extend_from_slice(step_tag);
1481 derive_idempotency_key(&bytes)
1482}
1483
1484#[must_use]
1485fn format_key(key: Option<IdempotencyKey>) -> String {
1486 key.map_or_else(|| "-".to_owned(), |k| hex16(k.as_bytes()))
1487}
1488
1489#[must_use]
1490fn format_saga(saga: Option<Saga>) -> String {
1491 saga.map_or_else(|| "-".to_owned(), |s| hex16(s.key().as_bytes()))
1492}
1493
1494#[must_use]
1495fn hex16(bytes: &[u8; 16]) -> String {
1496 use std::fmt::Write;
1497 let mut out = String::with_capacity(32);
1498 for byte in bytes {
1499 let _ = write!(out, "{byte:02x}");
1500 }
1501 out
1502}
1503
1504#[cfg(not(feature = "native"))]
1505#[derive(Debug)]
1506struct FallbackQueueReservation<'a> {
1507 queued_waiters: &'a AtomicUsize,
1508 released: bool,
1509}
1510
1511#[cfg(not(feature = "native"))]
1512impl FallbackQueueReservation<'_> {
1513 fn new(queued_waiters: &AtomicUsize) -> FallbackQueueReservation<'_> {
1514 FallbackQueueReservation {
1515 queued_waiters,
1516 released: false,
1517 }
1518 }
1519
1520 fn release(mut self) {
1521 if !self.released {
1522 self.queued_waiters.fetch_sub(1, Ordering::AcqRel);
1523 self.released = true;
1524 }
1525 }
1526}
1527
1528#[cfg(not(feature = "native"))]
1529impl Drop for FallbackQueueReservation<'_> {
1530 fn drop(&mut self) {
1531 if !self.released {
1532 self.queued_waiters.fetch_sub(1, Ordering::AcqRel);
1533 self.released = true;
1534 }
1535 }
1536}
1537
1538#[cfg(test)]
1539mod tests {
1540 use std::sync::Arc;
1541 use std::sync::atomic::{AtomicUsize, Ordering};
1542 use std::thread;
1543 use std::time::Duration;
1544 #[cfg(not(feature = "native"))]
1545 use std::time::Instant;
1546
1547 use super::*;
1548
1549 fn cap_token(seed: u8) -> RemoteCap {
1550 RemoteCap::from_bytes([seed; 16])
1551 }
1552
1553 fn segment_id(seed: u8) -> ObjectId {
1554 ObjectId::from_bytes([seed; 16])
1555 }
1556
1557 #[cfg(not(feature = "native"))]
1558 fn wait_for(condition: impl Fn() -> bool) {
1559 let deadline = Instant::now() + Duration::from_millis(250);
1560 while Instant::now() < deadline {
1561 if condition() {
1562 return;
1563 }
1564 thread::sleep(Duration::from_millis(1));
1565 }
1566 assert!(condition(), "timed out waiting for condition");
1567 }
1568
1569 #[test]
1570 fn test_remote_cap_required_for_network_io() {
1571 let cx = Cx::<cap::All>::new();
1572 let registry = ComputationRegistry::default();
1573 let executor = Executor::with_max_in_flight(1).unwrap();
1574 let computation = NamedComputation::new(ComputationName::SegmentStat, vec![1, 2, 3]);
1575 let trace = TraceContext::default();
1576
1577 let err = executor
1578 .execute(&cx, None, ®istry, &computation, &trace, || {
1579 Ok(vec![0xAA])
1580 })
1581 .unwrap_err();
1582
1583 assert!(matches!(err, FrankenError::Internal(_)));
1584 }
1585
1586 #[test]
1587 fn test_remote_cap_omitted_in_lab_fails_gracefully() {
1588 let cx = Cx::<cap::All>::new();
1589 let registry = ComputationRegistry::default();
1590 let executor = Executor::with_max_in_flight(1).unwrap();
1591 let computation = NamedComputation::new(ComputationName::SegmentStat, vec![0xAA]);
1592 let trace = TraceContext {
1593 trace_id: "lab-no-remote".to_owned(),
1594 lab_seed: Some(17),
1595 schedule_fingerprint: Some("sched-A".to_owned()),
1596 ..TraceContext::default()
1597 };
1598
1599 let err = executor
1600 .execute(&cx, None, ®istry, &computation, &trace, || {
1601 Ok(vec![0xBB])
1602 })
1603 .unwrap_err();
1604 assert!(matches!(err, FrankenError::Internal(_)));
1605 }
1606
1607 #[test]
1608 fn test_named_computation_registry_and_unregistered_rejection() {
1609 let mut registry = ComputationRegistry::new_empty();
1610 registry.register(ComputationName::SymbolGetRange);
1611 registry.register(ComputationName::SymbolPutBatch);
1612 registry.register(ComputationName::SegmentPut);
1613 registry.register(ComputationName::SegmentStat);
1614
1615 assert!(registry.validate(&ComputationName::SegmentPut).is_ok());
1616 assert!(
1617 registry
1618 .validate(&ComputationName::Custom("unregistered".to_owned()))
1619 .is_err()
1620 );
1621 }
1622
1623 #[test]
1624 fn test_named_computation_no_closure_shipping_canonical_bytes_deterministic() {
1625 let computation = NamedComputation::new(
1626 ComputationName::SymbolGetRange,
1627 b"obj=01;esi=0..7;epoch=2".to_vec(),
1628 );
1629 let bytes_a = computation.canonical_request_bytes().unwrap();
1630 let bytes_b = computation.canonical_request_bytes().unwrap();
1631 assert_eq!(bytes_a, bytes_b);
1632 let domain = REMOTE_IDEMPOTENCY_DOMAIN.as_bytes();
1633 assert!(bytes_a.windows(domain.len()).any(|window| window == domain));
1634 }
1635
1636 #[test]
1637 fn test_idempotency_key_deterministic() {
1638 let request = b"segment_put:abc";
1639 let key_a = derive_idempotency_key(request);
1640 let key_b = derive_idempotency_key(request);
1641 assert_eq!(key_a, key_b);
1642 }
1643
1644 #[test]
1645 fn test_idempotency_dedup_same_key_same_input() {
1646 let store = IdempotencyStore::new();
1647 let computation = ComputationName::SegmentPut;
1648 let request = b"segment_put:id=1";
1649 let key = derive_idempotency_key(request);
1650
1651 let first = store
1652 .register_or_replay(key, &computation, request, b"ok:first")
1653 .unwrap();
1654 let second = store
1655 .register_or_replay(key, &computation, request, b"ok:second")
1656 .unwrap();
1657
1658 assert!(matches!(first, IdempotencyDecision::StoredNew(_)));
1659 assert_eq!(second, IdempotencyDecision::Replayed(b"ok:first".to_vec()));
1660 }
1661
1662 #[test]
1663 fn test_idempotency_conflict_same_key_different_input() {
1664 let store = IdempotencyStore::new();
1665 let computation = ComputationName::SegmentPut;
1666 let first_request = b"segment_put:id=1";
1667 let second_request = b"segment_put:id=2";
1668 let key = derive_idempotency_key(first_request);
1669
1670 let _ = store
1671 .register_or_replay(key, &computation, first_request, b"ok:first")
1672 .unwrap();
1673
1674 let err = store
1675 .register_or_replay(key, &computation, second_request, b"ok:second")
1676 .unwrap_err();
1677 assert!(matches!(err, FrankenError::Internal(_)));
1678 }
1679
1680 #[test]
1681 fn test_lease_backed_liveness_expiry() {
1682 let trace = TraceContext {
1683 trace_id: "trace-lease".to_owned(),
1684 attempt: 1,
1685 ecs_epoch: 7,
1686 ..TraceContext::default()
1687 };
1688 let handle = LeaseBackedHandle::new(42, 1_000, 100, LeaseEscalation::Retry).unwrap();
1689 let status = handle.evaluate(1_200);
1690 assert_eq!(
1691 status,
1692 LeaseStatus::Expired {
1693 escalation: LeaseEscalation::Retry
1694 }
1695 );
1696 let err = handle.enforce(1_200, &trace).unwrap_err();
1697 assert!(matches!(err, FrankenError::BusyRecovery));
1698 }
1699
1700 #[test]
1701 fn test_e2e_remote_effects_saga_eviction_idempotent_restart() {
1702 let saga_key = derive_idempotency_key(b"saga:evict:segment-9");
1703 let saga_id = Saga::new(saga_key);
1704 let target_segment = segment_id(9);
1705 let payload = b"segment payload".to_vec();
1706
1707 let mut remote = InMemoryRemoteStore::new();
1708
1709 let mut first = EvictionSaga::new(saga_id, target_segment);
1710 first.upload(&mut remote, &payload).unwrap();
1711 let compensation = first.cancel();
1712 assert_eq!(compensation, EvictionCompensation::LocalRetained);
1713 assert_eq!(first.local_state(), LocalSegmentState::Present);
1714
1715 let mut restart = EvictionSaga::new(saga_id, target_segment);
1716 restart.upload(&mut remote, &payload).unwrap();
1717 restart.verify_remote(&remote).unwrap();
1718 restart.retire_local().unwrap();
1719
1720 assert_eq!(restart.local_state(), LocalSegmentState::Retired);
1721 assert_eq!(remote.upload_count(target_segment), 1);
1722 }
1723
1724 #[test]
1725 fn test_remote_bulkhead_concurrency_cap() {
1726 let executor = Arc::new(Executor::with_max_in_flight(2).unwrap());
1727 let registry = Arc::new(ComputationRegistry::default());
1728 let computation = Arc::new(NamedComputation::new(ComputationName::SegmentStat, vec![1]));
1729
1730 let active = Arc::new(AtomicUsize::new(0));
1731 let peak = Arc::new(AtomicUsize::new(0));
1732 let busy = Arc::new(AtomicUsize::new(0));
1733
1734 let start = Arc::new(std::sync::Barrier::new(5));
1735 let mut workers = Vec::new();
1736 for _ in 0..5 {
1737 let exec = Arc::clone(&executor);
1738 let reg = Arc::clone(®istry);
1739 let comp = Arc::clone(&computation);
1740 let active_ctr = Arc::clone(&active);
1741 let peak_ctr = Arc::clone(&peak);
1742 let busy_ctr = Arc::clone(&busy);
1743 let barrier = Arc::clone(&start);
1744 workers.push(thread::spawn(move || {
1745 let cx = Cx::<cap::All>::new();
1746 let trace = TraceContext::default();
1747 barrier.wait();
1748 let result = exec.execute(&cx, Some(cap_token(7)), ®, &comp, &trace, || {
1749 let now = active_ctr.fetch_add(1, Ordering::AcqRel) + 1;
1750 peak_ctr.fetch_max(now, Ordering::AcqRel);
1751 thread::sleep(Duration::from_millis(40));
1752 active_ctr.fetch_sub(1, Ordering::AcqRel);
1753 Ok(vec![1, 2, 3])
1754 });
1755 if matches!(result, Err(FrankenError::Busy)) {
1756 busy_ctr.fetch_add(1, Ordering::AcqRel);
1757 }
1758 }));
1759 }
1760 for worker in workers {
1761 worker.join().unwrap();
1762 }
1763
1764 assert!(busy.load(Ordering::Acquire) >= 3);
1765 assert!(peak.load(Ordering::Acquire) <= 2);
1766 }
1767
1768 #[test]
1769 fn test_remote_bulkhead_zero_means_auto() {
1770 let expected = conservative_remote_max_in_flight(available_parallelism_or_one());
1771 let executor = Executor::from_pragma_remote_max_in_flight(0).unwrap();
1772 assert_eq!(executor.max_in_flight(), expected);
1773 }
1774
1775 #[cfg(not(feature = "native"))]
1776 #[test]
1777 fn test_remote_bulkhead_fallback_queue_waits_for_capacity() {
1778 let executor = Arc::new(
1779 Executor::with_limits("fallback.test", 1, 1, Duration::from_millis(100)).unwrap(),
1780 );
1781 let held = executor.try_acquire_for_testing().unwrap();
1782
1783 let exec = Arc::clone(&executor);
1784 let waiter = thread::spawn(move || {
1785 let cx = Cx::<cap::All>::new();
1786 let permit = exec.acquire(&cx, "segment_stat", None, None, 0).unwrap();
1787 drop(permit);
1788 });
1789
1790 wait_for(|| executor.snapshot().queue_depth == 1);
1791 assert_eq!(executor.snapshot().active_permits, 1);
1792
1793 drop(held);
1794 waiter.join().unwrap();
1795
1796 let snapshot = executor.snapshot();
1797 assert_eq!(snapshot.queue_depth, 0);
1798 assert_eq!(snapshot.total_rejected, 0);
1799 assert_eq!(snapshot.total_cancelled, 0);
1800 }
1801
1802 #[cfg(not(feature = "native"))]
1803 #[test]
1804 fn test_remote_bulkhead_fallback_cancellation_releases_queue_slot() {
1805 let executor = Arc::new(
1806 Executor::with_limits("fallback.test", 1, 1, Duration::from_millis(100)).unwrap(),
1807 );
1808 let held = executor.try_acquire_for_testing().unwrap();
1809 let cx = Cx::<cap::All>::new();
1810 let waiter_cx = cx.clone();
1811
1812 let exec = Arc::clone(&executor);
1813 let waiter = thread::spawn(move || {
1814 let err = exec
1815 .acquire(&waiter_cx, "segment_stat", None, None, 0)
1816 .unwrap_err();
1817 assert!(matches!(err, FrankenError::Busy));
1818 });
1819
1820 wait_for(|| executor.snapshot().queue_depth == 1);
1821 cx.cancel();
1822 waiter.join().unwrap();
1823 drop(held);
1824
1825 let snapshot = executor.snapshot();
1826 assert_eq!(snapshot.queue_depth, 0);
1827 assert_eq!(snapshot.total_cancelled, 1);
1828 }
1829
1830 #[test]
1831 fn test_compaction_publish_saga_forward() {
1832 let saga_key = derive_idempotency_key(b"saga:compaction:forward");
1833 let saga_id = Saga::new(saga_key);
1834 let manifest_id = segment_id(99);
1835 let mut remote = InMemoryRemoteStore::new();
1836
1837 let mut saga = CompactionPublishSaga::new(saga_id, manifest_id);
1838 let segments = vec![
1839 (segment_id(11), b"seg-11".to_vec()),
1840 (segment_id(12), b"seg-12".to_vec()),
1841 ];
1842
1843 saga.stage_segments(&mut remote, &segments).unwrap();
1844 saga.publish_manifest(&mut remote, b"manifest-v2").unwrap();
1845 saga.update_locator().unwrap();
1846
1847 assert_eq!(saga.phase(), CompactionPhase::LocatorUpdated);
1848 assert!(saga.locator_updated());
1849 assert!(remote.has_segment(segment_id(11)));
1850 assert!(remote.has_segment(segment_id(12)));
1851 assert!(remote.has_segment(manifest_id));
1852 }
1853
1854 #[test]
1855 fn test_compaction_publish_saga_compensation_then_restart_idempotent() {
1856 let saga_key = derive_idempotency_key(b"saga:compaction:restart");
1857 let saga_id = Saga::new(saga_key);
1858 let manifest_id = segment_id(101);
1859 let seg_a = segment_id(21);
1860 let seg_b = segment_id(22);
1861 let mut remote = InMemoryRemoteStore::new();
1862 let segments = vec![(seg_a, b"seg-21".to_vec()), (seg_b, b"seg-22".to_vec())];
1863
1864 let mut first = CompactionPublishSaga::new(saga_id, manifest_id);
1865 first.stage_segments(&mut remote, &segments).unwrap();
1866 first.publish_manifest(&mut remote, b"manifest-v3").unwrap();
1867 let compensation = first.cancel(&mut remote);
1868 assert_eq!(compensation, CompactionCompensation::RemoteCleaned);
1869 assert!(!remote.has_segment(seg_a));
1870 assert!(!remote.has_segment(seg_b));
1871 assert!(!remote.has_segment(manifest_id));
1872
1873 let mut restart = CompactionPublishSaga::new(saga_id, manifest_id);
1874 restart.stage_segments(&mut remote, &segments).unwrap();
1875 restart
1876 .publish_manifest(&mut remote, b"manifest-v3")
1877 .unwrap();
1878 restart.update_locator().unwrap();
1879
1880 assert_eq!(restart.phase(), CompactionPhase::LocatorUpdated);
1881 assert!(restart.locator_updated());
1882 assert!(remote.has_segment(seg_a));
1883 assert!(remote.has_segment(seg_b));
1884 assert!(remote.has_segment(manifest_id));
1885 assert_eq!(remote.upload_count(seg_a), 1);
1886 assert_eq!(remote.upload_count(seg_b), 1);
1887 }
1888}