Skip to main content

fsqlite_core/
remote_effects.rs

1//! Remote effects contract primitives (§4.19.1-§4.19.5, `bd-numl`).
2//!
3//! This module provides:
4//! - explicit RemoteCap gating for remote execution paths,
5//! - named computations (no closure shipping),
6//! - deterministic idempotency key derivation + dedup store,
7//! - lease-backed liveness checks with deterministic escalation,
8//! - a cancellation-safe remote eviction saga skeleton.
9
10#[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// Tiered storage executor infrastructure — reserved for future ECS/Native mode
47// integration where cold pages are offloaded to remote object storage.
48#[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
56/// Domain separator for deterministic remote idempotency keys.
57pub const REMOTE_IDEMPOTENCY_DOMAIN: &str = "fsqlite:remote:v1";
58
59/// Named remote computations (§4.19.2).
60#[derive(Debug, Clone, PartialEq, Eq, Hash)]
61pub enum ComputationName {
62    /// `symbol_get_range(object_id, esi_lo, esi_hi, ecs_epoch)`
63    SymbolGetRange,
64    /// `symbol_put_batch(object_id, symbols[], ecs_epoch)`
65    SymbolPutBatch,
66    /// `segment_put(segment_id, bytes, ecs_epoch)`
67    SegmentPut,
68    /// `segment_stat(segment_id, ecs_epoch)`
69    SegmentStat,
70    /// Explicit extension point; not accepted unless registered.
71    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/// Serialized remote computation request payload.
104#[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    /// Build canonical bytes used for idempotency and auditing.
117    ///
118    /// Layout:
119    /// `[domain_len:u32][domain][tag:u8][name_len:u32][name][input_len:u32][input]`
120    ///
121    /// # Errors
122    ///
123    /// Returns `FrankenError::OutOfRange` if name/input lengths exceed `u32`.
124    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/// Registry of allowed named remote computations.
157#[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    /// Validate computation is registered for dispatch.
190    ///
191    /// # Errors
192    ///
193    /// Returns `FrankenError::Unsupported` if the computation is not registered.
194    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/// Structured remote-effect log context.
210#[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/// Derive deterministic idempotency key:
222/// `Trunc128(BLAKE3("fsqlite:remote:v1" || request_bytes))`.
223#[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/// Deduplication outcome for an idempotent remote request.
242#[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/// In-memory idempotency store for remote effects.
256#[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    /// Register outcome for `(key, computation, request)` or replay prior value.
268    ///
269    /// # Errors
270    ///
271    /// Returns `FrankenError::Internal` if the same idempotency key is reused
272    /// with different request bytes or a different computation name.
273    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
308/// Require a runtime RemoteCap in addition to type-level `HasRemote`.
309///
310/// # Errors
311///
312/// Returns `FrankenError::Internal` if `remote_cap` is `None`.
313pub 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/// Conservative default for `fsqlite.remote_max_in_flight` (balanced profile).
323///
324/// Formula: `clamp(P / 8, 1, 8)` where `P = available_parallelism`.
325#[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/// Snapshotted admission state for remote work.
338#[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/// Executor for remote operations guarded by a global bulkhead.
358#[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    /// Build executor from `PRAGMA fsqlite.remote_max_in_flight`.
375    ///
376    /// `0` means "auto" and resolves to the conservative balanced default.
377    ///
378    /// # Errors
379    ///
380    /// Returns `FrankenError::OutOfRange` when `remote_max_in_flight` is
381    /// non-zero but invalid.
382    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    /// Create with explicit in-flight limit.
391    ///
392    /// # Errors
393    ///
394    /// Returns `FrankenError::OutOfRange` if `max_in_flight == 0`.
395    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    /// Low-risk first production rollout slice for `bd-28z4i.6`: keep the
413    /// existing conservative concurrency cap, but allow one bounded waiter so
414    /// `&Cx` cancellation can unwind tiered-storage remote admission cleanly.
415    #[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    /// Create a named executor with explicit queue bounds.
430    ///
431    /// # Errors
432    ///
433    /// Returns `FrankenError::OutOfRange` when any configured limit does not
434    /// fit the underlying admission controller.
435    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    /// Execute a named remote computation through the global remote bulkhead.
943    ///
944    /// # Errors
945    ///
946    /// Returns:
947    /// - `FrankenError::Internal` when `remote_cap` is absent,
948    /// - `FrankenError::Unsupported` for unregistered computations,
949    /// - `FrankenError::Busy` when the remote bulkhead is saturated,
950    /// - or any error from `operation`.
951    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/// Lease-expiry escalation policy.
1003#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1004pub enum LeaseEscalation {
1005    Cancel,
1006    Retry,
1007    Fail,
1008}
1009
1010/// Lease liveness result.
1011#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1012pub enum LeaseStatus {
1013    Live,
1014    Expired { escalation: LeaseEscalation },
1015}
1016
1017/// Lease-backed remote handle metadata.
1018#[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    /// Create a lease-backed handle.
1028    ///
1029    /// # Errors
1030    ///
1031    /// Returns `FrankenError::OutOfRange` if `ttl_millis == 0`.
1032    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    /// Enforce lease validity and map expiry to deterministic escalation errors.
1065    ///
1066    /// # Errors
1067    ///
1068    /// Returns:
1069    /// - `FrankenError::Busy` for `Cancel`,
1070    /// - `FrankenError::BusyRecovery` for `Retry`,
1071    /// - `FrankenError::LockFailed` for `Fail`.
1072    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/// Local deterministic remote segment store for tests.
1101#[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    /// Idempotent segment upload keyed by idempotency key.
1121    ///
1122    /// # Errors
1123    ///
1124    /// Returns `FrankenError::Internal` when an existing idempotency key is
1125    /// reused with a different segment/payload.
1126    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                // Preserve idempotency while ensuring deterministic replay can
1136                // reconstruct remote-visible state after compensation cleanup.
1137                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/// Eviction saga phase.
1175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1176pub enum EvictionPhase {
1177    Init,
1178    Uploaded,
1179    Verified,
1180    Retired,
1181    Cancelled,
1182}
1183
1184/// Local segment state during eviction.
1185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1186pub enum LocalSegmentState {
1187    Present,
1188    Retired,
1189}
1190
1191/// Compensation outcome when cancelling an eviction saga.
1192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1193pub enum EvictionCompensation {
1194    LocalRetained,
1195    RollbackRequired,
1196}
1197
1198/// L2->L3 eviction saga skeleton (`upload -> verify -> retire`).
1199#[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    /// Upload step (`segment_put`).
1236    ///
1237    /// # Errors
1238    ///
1239    /// Returns `FrankenError::Internal` when called from an invalid phase or if
1240    /// remote idempotency validation fails.
1241    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    /// Verify step (`segment_stat`).
1254    ///
1255    /// # Errors
1256    ///
1257    /// Returns `FrankenError::Internal` when called from an invalid phase or if
1258    /// the segment is not present remotely.
1259    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    /// Retire local segment after remote verification.
1276    ///
1277    /// # Errors
1278    ///
1279    /// Returns `FrankenError::Internal` when called from an invalid phase.
1280    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    /// Cancel saga; before retire we retain local data for safe replay.
1293    #[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/// Compaction publish saga phase (`write segments -> publish -> update locator`).
1311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1312pub enum CompactionPhase {
1313    Init,
1314    SegmentsStaged,
1315    Published,
1316    LocatorUpdated,
1317    Cancelled,
1318}
1319
1320/// Compensation outcome when cancelling compaction publication.
1321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1322pub enum CompactionCompensation {
1323    RemoteCleaned,
1324    RollbackRequired,
1325}
1326
1327/// Compaction publication saga skeleton with deterministic compensation.
1328#[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    /// Stage replacement segments for compaction publication.
1360    ///
1361    /// # Errors
1362    ///
1363    /// Returns `FrankenError::OutOfRange` when `segments` is empty, or
1364    /// `FrankenError::Internal` when called from an invalid phase.
1365    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    /// Publish a compaction manifest after segment staging.
1398    ///
1399    /// # Errors
1400    ///
1401    /// Returns `FrankenError::Internal` when called from an invalid phase.
1402    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    /// Update locators/manifests to point at the newly published compaction output.
1424    ///
1425    /// # Errors
1426    ///
1427    /// Returns `FrankenError::Internal` when called from an invalid phase.
1428    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    /// Cancel compaction publication.
1441    ///
1442    /// Before locator update, deterministic compensation removes staged remote
1443    /// objects and leaves local locator state unchanged.
1444    #[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, &registry, &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, &registry, &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(&registry);
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)), &reg, &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}