Skip to main content

laser_sdk/kv/
coordination.rs

1use crate::error::LaserError;
2use crate::iggy::prelude::Client;
3use crate::kv::{
4    AGDX_KV_CAS_FENCED_CODE, AGDX_KV_GET_CODE, AGDX_KV_LEASE_CODE, AGDX_KV_LEASE_RENEW_CODE,
5    AGDX_KV_RELEASE_CODE, KvCasFenced, KvEntry, KvGet, KvLease, KvLeaseRenew, KvRelease, Lease,
6};
7use crate::laser::Laser;
8use laser_wire::framing::encode_named;
9use laser_wire::kv::{KvOutcome, KvReply};
10use laser_wire::mutation::{MANAGED_REQUEST_VERSION, ManagedRequestEnvelope};
11use laser_wire::validate::Validate;
12use std::future::Future;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::time::Duration;
16use tokio::sync::Mutex;
17
18/// Per-attempt bound before an in-flight coordination request is declared
19/// ambiguous and transport retirement begins. Retirement still waits for any
20/// cancellation-safe Iggy request task to stop before recovery proceeds.
21pub const DEFAULT_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10);
22
23/// The transport seam under [`FencedLeaseClient`]: send one managed command
24/// frame and return the raw reply bytes, or discard connection state after a
25/// cancellation or ambiguous response. Injectable, so the client's envelope,
26/// retry, and decode behavior unit-tests without a server.
27#[trait_variant::make(ManagedKvTransport: Send)]
28pub trait LocalManagedKvTransport {
29    /// Establish the transport and perform deterministic capability checks
30    /// before a request enters the ambiguity window. Connection refusal,
31    /// authentication failure, and unsupported deployments surface unchanged.
32    fn ready(&self) -> impl Future<Output = Result<(), LaserError>> {
33        async { Ok(()) }
34    }
35    /// Send one framed managed command and return the raw reply bytes.
36    async fn send(&self, code: u32, frame: Vec<u8>) -> Result<Vec<u8>, LaserError>;
37    /// Actively close and discard any connection state. Called after a timed-out
38    /// or failed attempt whose request may still be in flight. This method must
39    /// stop that work before it returns, so its remaining lifetime cannot outlive
40    /// an acquisition recovery wait and a lockstep transport cannot read a stale
41    /// reply as the answer to the next request.
42    async fn reset(&self);
43
44    /// Close the transport permanently when supported. The default retires its connection.
45    fn close(&self) -> impl Future<Output = ()>
46    where
47        Self: Sync,
48    {
49        async { self.reset().await }
50    }
51}
52
53/// The object-safe counterpart of [`ManagedKvTransport`], for a consumer that
54/// must hold its transport behind a pointer instead of as a generic parameter:
55/// a state-provider seam reached through a `#[non_exhaustive]` enum cannot grow
56/// a type parameter without leaking it through every construction and call
57/// site. [`ManagedKvTransport`]'s methods return an anonymous future per
58/// implementation, so that trait has no vtable. This one boxes the future to
59/// get one.
60///
61/// Do not implement this by hand. Every [`ManagedKvTransport`] already
62/// implements it, and [`SharedKvTransport`] implements [`ManagedKvTransport`]
63/// back, so a runtime-selected transport drops straight into
64/// [`FencedLeaseClient`] with the same envelope, retry, and decode behavior:
65///
66/// ```no_run
67/// # use std::sync::Arc;
68/// # use laser_sdk::kv::{DedicatedKvTransport, FencedLeaseClient, SharedKvTransport};
69/// let transport: SharedKvTransport = Arc::new(DedicatedKvTransport::new("iggy+tcp://…"));
70/// let client = FencedLeaseClient::new(transport);
71/// ```
72///
73/// The boxing costs one allocation per request, against a network round trip
74/// under an in-flight request timeout.
75#[async_trait::async_trait]
76pub trait DynManagedKvTransport: Send + Sync {
77    /// See [`ManagedKvTransport::ready`].
78    async fn ready(&self) -> Result<(), LaserError>;
79    /// See [`ManagedKvTransport::send`].
80    async fn send(&self, code: u32, frame: Vec<u8>) -> Result<Vec<u8>, LaserError>;
81    /// See [`ManagedKvTransport::reset`]. The same contract holds: in-flight
82    /// work must stop before this returns.
83    async fn reset(&self);
84
85    async fn close(&self) {
86        self.reset().await;
87    }
88}
89
90/// A [`ManagedKvTransport`] chosen at runtime: the shape a consumer stores when
91/// the concrete transport (dedicated connection, stub, in-process fake) is a
92/// configuration decision rather than a type-level one.
93pub type SharedKvTransport = Arc<dyn DynManagedKvTransport>;
94
95#[async_trait::async_trait]
96impl<T: ManagedKvTransport + Send + Sync> DynManagedKvTransport for T {
97    async fn ready(&self) -> Result<(), LaserError> {
98        ManagedKvTransport::ready(self).await
99    }
100
101    async fn send(&self, code: u32, frame: Vec<u8>) -> Result<Vec<u8>, LaserError> {
102        ManagedKvTransport::send(self, code, frame).await
103    }
104
105    async fn reset(&self) {
106        ManagedKvTransport::reset(self).await;
107    }
108
109    async fn close(&self) {
110        ManagedKvTransport::close(self).await;
111    }
112}
113
114// The other direction, so boxing a transport does not cost the typed client:
115// dispatch goes through the vtable to the concrete transport's own
116// implementation, never back into this one.
117impl ManagedKvTransport for SharedKvTransport {
118    async fn ready(&self) -> Result<(), LaserError> {
119        DynManagedKvTransport::ready(&**self).await
120    }
121
122    async fn send(&self, code: u32, frame: Vec<u8>) -> Result<Vec<u8>, LaserError> {
123        DynManagedKvTransport::send(&**self, code, frame).await
124    }
125
126    async fn reset(&self) {
127        DynManagedKvTransport::reset(&**self).await;
128    }
129
130    async fn close(&self) {
131        DynManagedKvTransport::close(&**self).await;
132    }
133}
134
135/// A coordination transport over its own dedicated connection, built lazily
136/// from a connection string and discarded whole on [`reset`]. Dedicated
137/// because a raw managed request holds a lockstep client for its full bound:
138/// borrowing a shared producer client would let one slow coordination call
139/// stall unrelated traffic (and the reverse). The first readiness check after
140/// a reset reconnects and re-verifies the `kv_fenced_leases` capability, failing
141/// closed against a pre-fencing deployment. That deployment keeps its one
142/// connection, so each later call answers `Unsupported` without a new login.
143///
144/// [`reset`]: ManagedKvTransport::reset
145pub struct DedicatedKvTransport {
146    connection_string: String,
147    slot: Mutex<Option<Laser>>,
148    closed: AtomicBool,
149    retirement: Mutex<()>,
150}
151
152impl DedicatedKvTransport {
153    /// A transport that will connect to `connection_string` on first use.
154    pub fn new(connection_string: impl Into<String>) -> Self {
155        Self {
156            connection_string: connection_string.into(),
157            slot: Mutex::new(None),
158            closed: AtomicBool::new(false),
159            retirement: Mutex::new(()),
160        }
161    }
162
163    /// Close this dedicated transport. Reset retires a connection and permits later reuse.
164    pub async fn close(&self) {
165        self.closed.store(true, Ordering::Release);
166        ManagedKvTransport::reset(self).await;
167    }
168
169    async fn client(&self) -> Result<Laser, LaserError> {
170        let _retirement = self.retirement.lock().await;
171        let mut slot = self.slot.lock().await;
172        if self.closed.load(Ordering::Acquire) {
173            return Err(crate::iggy::prelude::IggyError::ClientShutdown.into());
174        }
175        let (laser, reused) = match slot.as_ref() {
176            Some(laser) => (laser.clone(), true),
177            None => {
178                let laser = Laser::connect(&self.connection_string).await?;
179                *slot = Some(laser.clone());
180                (laser, false)
181            }
182        };
183        let mut capabilities = laser.capabilities().await;
184        if reused && !capabilities.kv.fenced_leases {
185            capabilities = laser.refresh_capabilities().await;
186        }
187        if !capabilities.kv.fenced_leases {
188            return Err(LaserError::unsupported_feature(
189                "kv",
190                "coordination",
191                "the fenced-lease contract is not advertised by this deployment",
192            ));
193        }
194        Ok(laser)
195    }
196}
197
198impl ManagedKvTransport for DedicatedKvTransport {
199    async fn ready(&self) -> Result<(), LaserError> {
200        self.client().await.map(|_| ())
201    }
202
203    async fn send(&self, code: u32, frame: Vec<u8>) -> Result<Vec<u8>, LaserError> {
204        let laser = self.slot.lock().await.as_ref().cloned().ok_or_else(|| {
205            LaserError::HandlerConfig("coordination transport is not ready".to_owned())
206        })?;
207        laser
208            .send_raw_preframed(code, frame)
209            .await
210            .map_err(LaserError::from)
211    }
212
213    async fn reset(&self) {
214        let _retirement = self.retirement.lock().await;
215        let retired = self.slot.lock().await.take();
216        if let Some(laser) = retired {
217            let _ = laser.client().shutdown().await;
218        }
219    }
220
221    async fn close(&self) {
222        DedicatedKvTransport::close(self).await;
223    }
224}
225
226/// One prepared coordination mutation: the command code and exact framed
227/// bytes, `ManagedRequestEnvelope` included, minted once and bound to the
228/// [`FencedLeaseClient`] that prepared it. It is deliberately not `Clone`, so
229/// one operation identity cannot be fanned out across transports or
230/// deployments.
231#[derive(Debug)]
232pub struct PreparedMutation {
233    client_id: u128,
234    code: u32,
235    operation_id: u128,
236    frame: Vec<u8>,
237    ambiguous_recovery: AmbiguousMutationRecovery,
238}
239
240/// The recovery a caller must use when execution of a prepared mutation
241/// returns [`LaserError::AmbiguousMutation`]. This keeps operation-specific
242/// recovery inspectable instead of asking a generic retry classifier to guess.
243#[derive(Clone, Copy, Debug, PartialEq, Eq)]
244pub enum AmbiguousMutationRecovery {
245    /// Do not acquire again until this duration has elapsed after the
246    /// ambiguous result. A grant never outlives its requested TTL.
247    WaitForLeaseExpiry(Duration),
248    /// Repeat this exact [`PreparedMutation`], preserving its operation id.
249    RepeatPrepared,
250    /// Read the target and reconcile through the fenced CAS precondition.
251    ReconcileTargetPrecondition,
252}
253
254impl PreparedMutation {
255    /// The stable operation identity this mutation carries across retries.
256    pub fn operation_id(&self) -> u128 {
257        self.operation_id
258    }
259
260    /// The operation-specific action required after an ambiguous result.
261    pub fn ambiguous_recovery(&self) -> AmbiguousMutationRecovery {
262        self.ambiguous_recovery
263    }
264
265    fn build(
266        client_id: u128,
267        code: u32,
268        request: &impl serde::Serialize,
269        ambiguous_recovery: AmbiguousMutationRecovery,
270    ) -> Result<Self, LaserError> {
271        let payload = encode_named(request)
272            .map_err(|error| LaserError::Codec(format!("encode request: {error}")))?;
273        let operation_id = u128::from(ulid::Ulid::generate());
274        let frame = encode_named(&ManagedRequestEnvelope {
275            v: MANAGED_REQUEST_VERSION,
276            operation_id,
277            payload,
278        })
279        .map_err(|error| LaserError::Codec(format!("encode envelope: {error}")))?;
280        Ok(Self {
281            client_id,
282            code,
283            operation_id,
284            frame,
285            ambiguous_recovery,
286        })
287    }
288}
289
290/// The typed client for the fenced-lease coordination contract: acquire,
291/// renew, and release a revocable lease, apply a fenced compare-and-swap, and
292/// run the barriered read, over an injectable [`ManagedKvTransport`].
293///
294/// The mutation flow is two-phase by design. `prepare_*` validates the request
295/// and mints the operation id once. The matching execute method sends the
296/// exact frame under a per-attempt timeout. A timeout or transport failure
297/// resets the transport and surfaces as [`LaserError::AmbiguousMutation`]. It
298/// is not generically retryable: [`PreparedMutation::ambiguous_recovery`]
299/// provides the machine-readable recovery action. Renew and release may repeat
300/// the same prepared request, a fenced CAS reconciles through its target
301/// precondition, while an acquire whose reply was lost must wait through its
302/// requested maximum TTL before a fresh acquisition. The transport contract
303/// does not assume operation-id deduplication. Unexpected reply outcomes are
304/// rejected fail-closed, never coerced.
305pub struct FencedLeaseClient<T> {
306    client_id: u128,
307    transport: T,
308    attempt_timeout: Duration,
309    closed: AtomicBool,
310    retirement: Mutex<()>,
311}
312
313impl FencedLeaseClient<DedicatedKvTransport> {
314    /// A client over its own dedicated connection to `connection_string`,
315    /// connected lazily on first use.
316    pub fn connect_dedicated(connection_string: impl Into<String>) -> Self {
317        Self::new(DedicatedKvTransport::new(connection_string))
318    }
319}
320
321impl<T: ManagedKvTransport + Sync> FencedLeaseClient<T> {
322    /// A client over `transport` with the default per-attempt timeout.
323    pub fn new(transport: T) -> Self {
324        Self {
325            client_id: u128::from(ulid::Ulid::generate()),
326            transport,
327            attempt_timeout: DEFAULT_ATTEMPT_TIMEOUT,
328            closed: AtomicBool::new(false),
329            retirement: Mutex::new(()),
330        }
331    }
332
333    /// Returns the client with a caller-chosen per-attempt timeout. A zero
334    /// duration is rejected before connection or send as `LaserError::Invalid`.
335    #[must_use]
336    pub fn with_attempt_timeout(mut self, timeout: Duration) -> Self {
337        self.attempt_timeout = timeout;
338        self
339    }
340
341    /// Close the client and retire its transport. Later executions fail before readiness or send.
342    pub async fn close(&self) {
343        let _retirement = self.retirement.lock().await;
344        if !self.closed.swap(true, Ordering::AcqRel) {
345            self.transport.close().await;
346        }
347    }
348
349    /// Prepare a lease acquisition. Validation happens here, once, so a retry
350    /// can never diverge from what was first sent.
351    pub fn prepare_acquire(&self, request: &KvLease) -> Result<PreparedMutation, LaserError> {
352        request.validate()?;
353        PreparedMutation::build(
354            self.client_id,
355            AGDX_KV_LEASE_CODE,
356            request,
357            AmbiguousMutationRecovery::WaitForLeaseExpiry(Duration::from_micros(
358                request.lease_ttl_micros,
359            )),
360        )
361    }
362
363    /// Prepare a lease renewal.
364    pub fn prepare_renew(&self, request: &KvLeaseRenew) -> Result<PreparedMutation, LaserError> {
365        request.validate()?;
366        PreparedMutation::build(
367            self.client_id,
368            AGDX_KV_LEASE_RENEW_CODE,
369            request,
370            AmbiguousMutationRecovery::RepeatPrepared,
371        )
372    }
373
374    /// Prepare a lease release.
375    pub fn prepare_release(&self, request: &KvRelease) -> Result<PreparedMutation, LaserError> {
376        request.validate()?;
377        PreparedMutation::build(
378            self.client_id,
379            AGDX_KV_RELEASE_CODE,
380            request,
381            AmbiguousMutationRecovery::RepeatPrepared,
382        )
383    }
384
385    /// Prepare a fenced compare-and-swap.
386    pub fn prepare_cas_fenced(
387        &self,
388        request: &KvCasFenced,
389    ) -> Result<PreparedMutation, LaserError> {
390        request.validate()?;
391        PreparedMutation::build(
392            self.client_id,
393            AGDX_KV_CAS_FENCED_CODE,
394            request,
395            AmbiguousMutationRecovery::ReconcileTargetPrecondition,
396        )
397    }
398
399    /// Execute a prepared acquisition, returning the granted [`Lease`].
400    pub async fn acquire(&self, op: &PreparedMutation) -> Result<Lease, LaserError> {
401        match self
402            .execute_with_timeout(op, AGDX_KV_LEASE_CODE, "acquire", self.attempt_timeout)
403            .await?
404        {
405            KvOutcome::Leased {
406                lease_token,
407                granted_ttl_micros,
408                position,
409            } => Ok(Lease {
410                token: lease_token,
411                granted_ttl: Duration::from_micros(granted_ttl_micros),
412                position,
413            }),
414            other => Err(unexpected("acquire", &other)),
415        }
416    }
417
418    /// Execute a prepared renewal, returning the extended [`Lease`] (same
419    /// token, fresh TTL and position).
420    pub async fn renew(&self, op: &PreparedMutation) -> Result<Lease, LaserError> {
421        self.renew_with_timeout(op, self.attempt_timeout).await
422    }
423
424    pub(crate) async fn renew_with_timeout(
425        &self,
426        op: &PreparedMutation,
427        timeout: Duration,
428    ) -> Result<Lease, LaserError> {
429        match self
430            .execute_with_timeout(op, AGDX_KV_LEASE_RENEW_CODE, "renew", timeout)
431            .await?
432        {
433            KvOutcome::Renewed {
434                lease_token,
435                granted_ttl_micros,
436                position,
437            } => Ok(Lease {
438                token: lease_token,
439                granted_ttl: Duration::from_micros(granted_ttl_micros),
440                position,
441            }),
442            other => Err(unexpected("renew", &other)),
443        }
444    }
445
446    /// Execute a prepared release. `true` when a held lease was released,
447    /// `false` when none was held (idempotent).
448    pub async fn release(&self, op: &PreparedMutation) -> Result<bool, LaserError> {
449        match self
450            .execute_with_timeout(op, AGDX_KV_RELEASE_CODE, "release", self.attempt_timeout)
451            .await?
452        {
453            KvOutcome::Released(released) => Ok(released),
454            other => Err(unexpected("release", &other)),
455        }
456    }
457
458    /// Execute a prepared fenced compare-and-swap, returning the entry's new
459    /// version.
460    pub async fn cas_fenced(&self, op: &PreparedMutation) -> Result<u64, LaserError> {
461        match self
462            .execute_with_timeout(
463                op,
464                AGDX_KV_CAS_FENCED_CODE,
465                "cas_fenced",
466                self.attempt_timeout,
467            )
468            .await?
469        {
470            KvOutcome::Committed { version } => Ok(version),
471            other => Err(unexpected("cas_fenced", &other)),
472        }
473    }
474
475    /// The (optionally barriered) read. A read is not a managed mutation, so
476    /// it rides unframed with no operation id, and a timeout is a plain
477    /// retryable [`LaserError::Timeout`], never ambiguous. A barrier the fold
478    /// cannot reach surfaces as the typed
479    /// [`KvError::Stale`](laser_wire::kv::KvError::Stale), which the caller
480    /// must treat as a retryable orchestration failure, never as an absent
481    /// value.
482    pub async fn get(&self, request: &KvGet) -> Result<Option<KvEntry>, LaserError> {
483        Self::ensure_attempt_timeout(self.attempt_timeout)?;
484        self.ready().await?;
485        let frame = encode_named(request)
486            .map_err(|error| LaserError::Codec(format!("encode request: {error}")))?;
487        let send = self.transport.send(AGDX_KV_GET_CODE, frame);
488        let reply = match tokio::time::timeout(self.attempt_timeout, send).await {
489            Err(_elapsed) => {
490                self.transport.reset().await;
491                return Err(LaserError::Timeout("kv coordination get"));
492            }
493            Ok(Err(error)) => {
494                self.transport.reset().await;
495                return Err(error);
496            }
497            Ok(Ok(bytes)) => bytes,
498        };
499        match decode_reply(&reply)? {
500            KvOutcome::Value(entry) => Ok(entry),
501            other => Err(unexpected("get", &other)),
502        }
503    }
504
505    async fn execute_with_timeout(
506        &self,
507        op: &PreparedMutation,
508        expected_code: u32,
509        what: &'static str,
510        attempt_timeout: Duration,
511    ) -> Result<KvOutcome, LaserError> {
512        Self::ensure_attempt_timeout(attempt_timeout)?;
513        if op.client_id != self.client_id {
514            return Err(LaserError::Invalid(
515                "prepared mutation belongs to a different fenced-lease client".to_owned(),
516            ));
517        }
518        // A prepared release replayed through `acquire` would coerce outcomes
519        // across operations. The pairing is checked, not assumed.
520        if op.code != expected_code {
521            return Err(LaserError::Invalid(format!(
522                "prepared mutation carries code {} but was executed as {what}",
523                op.code
524            )));
525        }
526        self.ready_with_timeout(attempt_timeout).await?;
527        let send = self.transport.send(op.code, op.frame.clone());
528        let reply = match tokio::time::timeout(attempt_timeout, send).await {
529            Err(_elapsed) => {
530                self.transport.reset().await;
531                return Err(LaserError::AmbiguousMutation(format!(
532                    "{what} attempt exceeded {:?} and requires operation-specific recovery",
533                    attempt_timeout,
534                )));
535            }
536            Ok(Err(error)) if !error.is_retryable() || error.is_permission_denied() => {
537                self.transport.reset().await;
538                return Err(error);
539            }
540            // Once send has started, a retryable transport failure is ambiguous:
541            // the server may have applied the mutation before the connection died.
542            Ok(Err(error)) => {
543                self.transport.reset().await;
544                return Err(LaserError::AmbiguousMutation(format!(
545                    "{what} transport failed mid-request: {error}"
546                )));
547            }
548            Ok(Ok(bytes)) => bytes,
549        };
550        decode_reply(&reply)
551    }
552
553    fn ensure_attempt_timeout(timeout: Duration) -> Result<(), LaserError> {
554        if timeout.is_zero() {
555            Err(LaserError::Invalid(
556                "coordination attempt timeout must be greater than zero".to_owned(),
557            ))
558        } else {
559            Ok(())
560        }
561    }
562
563    async fn ready(&self) -> Result<(), LaserError> {
564        Self::ensure_attempt_timeout(self.attempt_timeout)?;
565        self.ready_with_timeout(self.attempt_timeout).await
566    }
567
568    async fn ready_with_timeout(&self, timeout: Duration) -> Result<(), LaserError> {
569        if self.closed.load(Ordering::Acquire) {
570            return Err(crate::iggy::prelude::IggyError::ClientShutdown.into());
571        }
572        match tokio::time::timeout(timeout, self.transport.ready()).await {
573            Err(_elapsed) => {
574                self.transport.reset().await;
575                Err(LaserError::Timeout("kv coordination connect"))
576            }
577            // A deployment without the capability stays connected, so the next call
578            // answers the same way instead of reconnecting and logging in again.
579            Ok(Err(error @ LaserError::Unsupported { .. })) => Err(error),
580            Ok(Err(error)) => {
581                self.transport.reset().await;
582                Err(error)
583            }
584            Ok(Ok(())) => Ok(()),
585        }
586    }
587}
588
589fn decode_reply(payload: &[u8]) -> Result<KvOutcome, LaserError> {
590    match crate::error::decode_managed_reply::<KvReply>(payload)? {
591        KvReply::Ok(outcome) => Ok(outcome),
592        KvReply::Err(error) => Err(error.into()),
593        // The reply enum is non_exhaustive: a newer server's unknown variant
594        // is a protocol surprise, not an outcome.
595        _ => Err(LaserError::Protocol(
596            "kv coordination: unknown reply variant".to_owned(),
597        )),
598    }
599}
600
601fn unexpected(what: &str, outcome: &KvOutcome) -> LaserError {
602    LaserError::Protocol(format!(
603        "kv coordination {what}: unexpected outcome {outcome:?}"
604    ))
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use crate::kv::{CasExpect, KV_LEASE_OP_VERSION, KV_OP_VERSION, KvError};
611    use laser_wire::framing::decode_named;
612    use laser_wire::mutation::MutationPosition;
613    use std::sync::Mutex as StdMutex;
614    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
615
616    struct StubTransport {
617        replies: StdMutex<Vec<Result<Vec<u8>, LaserError>>>,
618        sent: StdMutex<Vec<(u32, Vec<u8>)>>,
619        resets: AtomicUsize,
620        delay: Option<Duration>,
621    }
622
623    impl StubTransport {
624        fn answering(replies: Vec<Result<Vec<u8>, LaserError>>) -> Self {
625            Self {
626                replies: StdMutex::new(replies),
627                sent: StdMutex::new(Vec::new()),
628                resets: AtomicUsize::new(0),
629                delay: None,
630            }
631        }
632
633        fn hanging() -> Self {
634            Self {
635                replies: StdMutex::new(Vec::new()),
636                sent: StdMutex::new(Vec::new()),
637                resets: AtomicUsize::new(0),
638                delay: Some(Duration::from_secs(60)),
639            }
640        }
641    }
642
643    impl ManagedKvTransport for &StubTransport {
644        async fn send(&self, code: u32, frame: Vec<u8>) -> Result<Vec<u8>, LaserError> {
645            self.sent.lock().expect("sent lock").push((code, frame));
646            if let Some(delay) = self.delay {
647                tokio::time::sleep(delay).await;
648            }
649            self.replies.lock().expect("replies lock").remove(0)
650        }
651
652        async fn reset(&self) {
653            self.resets.fetch_add(1, Ordering::SeqCst);
654        }
655    }
656
657    #[derive(Default)]
658    struct UnreadyTransport {
659        resets: AtomicUsize,
660    }
661
662    impl ManagedKvTransport for &UnreadyTransport {
663        async fn ready(&self) -> Result<(), LaserError> {
664            Err(LaserError::unsupported_feature(
665                "kv",
666                "coordination",
667                "fenced leases are unavailable",
668            ))
669        }
670
671        async fn send(&self, _code: u32, _frame: Vec<u8>) -> Result<Vec<u8>, LaserError> {
672            panic!("a permanent setup failure must stop before send")
673        }
674
675        async fn reset(&self) {
676            self.resets.fetch_add(1, Ordering::SeqCst);
677        }
678    }
679
680    struct SlowResetTransport {
681        reset_finished: AtomicBool,
682    }
683
684    impl ManagedKvTransport for &SlowResetTransport {
685        async fn send(&self, _code: u32, _frame: Vec<u8>) -> Result<Vec<u8>, LaserError> {
686            std::future::pending().await
687        }
688
689        async fn reset(&self) {
690            tokio::time::sleep(Duration::from_millis(25)).await;
691            self.reset_finished.store(true, Ordering::SeqCst);
692        }
693    }
694
695    struct SlowReadyTransport {
696        ready_delay: Duration,
697        send_delay: Duration,
698    }
699
700    impl ManagedKvTransport for &SlowReadyTransport {
701        async fn ready(&self) -> Result<(), LaserError> {
702            tokio::time::sleep(self.ready_delay).await;
703            Ok(())
704        }
705
706        async fn send(&self, _code: u32, _frame: Vec<u8>) -> Result<Vec<u8>, LaserError> {
707            tokio::time::sleep(self.send_delay).await;
708            ok_reply(KvOutcome::Leased {
709                lease_token: 9,
710                granted_ttl_micros: 30_000_000,
711                position: position(),
712            })
713        }
714
715        async fn reset(&self) {}
716    }
717
718    fn ok_reply(outcome: KvOutcome) -> Result<Vec<u8>, LaserError> {
719        Ok(encode_named(&KvReply::Ok(outcome)).expect("reply encodes"))
720    }
721
722    fn err_reply(error: KvError) -> Result<Vec<u8>, LaserError> {
723        Ok(encode_named(&KvReply::Err(error)).expect("reply encodes"))
724    }
725
726    fn lease_request() -> KvLease {
727        KvLease {
728            v: KV_LEASE_OP_VERSION,
729            namespace: "connectors.coordination".to_owned(),
730            key: b"source-owner".to_vec(),
731            lease_ttl_micros: 30_000_000,
732            holder_id: "node-1".to_owned(),
733            subject_user_id: Some(42),
734        }
735    }
736
737    fn position() -> MutationPosition {
738        MutationPosition {
739            topic_generation: 1,
740            partition: 0,
741            offset: 512,
742        }
743    }
744
745    #[tokio::test]
746    async fn given_a_prepared_renewal_when_retried_then_should_reuse_the_exact_frame_and_id() {
747        let stub = StubTransport::answering(vec![
748            Err(LaserError::Timeout("boom")),
749            ok_reply(KvOutcome::Renewed {
750                lease_token: 7,
751                granted_ttl_micros: 30_000_000,
752                position: position(),
753            }),
754        ]);
755        let client = FencedLeaseClient::new(&stub);
756        let request = KvLeaseRenew {
757            v: KV_LEASE_OP_VERSION,
758            namespace: "connectors.coordination".to_owned(),
759            key: b"source-owner".to_vec(),
760            holder_id: "node-1".to_owned(),
761            subject_user_id: Some(42),
762            lease_token: 7,
763            lease_ttl_micros: 30_000_000,
764        };
765        let op = client.prepare_renew(&request).expect("prepares");
766        assert_eq!(
767            op.ambiguous_recovery(),
768            AmbiguousMutationRecovery::RepeatPrepared
769        );
770
771        let first = client.renew(&op).await;
772        assert!(
773            matches!(first, Err(LaserError::AmbiguousMutation(_))),
774            "a transport failure is ambiguous, got {first:?}"
775        );
776        let lease = client.renew(&op).await.expect("retry succeeds");
777        assert_eq!(lease.token, 7);
778        assert_eq!(lease.position, position());
779
780        let sent = stub.sent.lock().expect("sent lock");
781        assert_eq!(sent.len(), 2);
782        assert_eq!(sent[0], sent[1], "the retry reuses the exact frame");
783        let envelope: ManagedRequestEnvelope =
784            decode_named(&sent[0].1).expect("the frame is a managed envelope");
785        assert_eq!(envelope.operation_id, op.operation_id());
786        assert_ne!(envelope.operation_id, 0);
787        let inner: KvLeaseRenew =
788            decode_named(&envelope.payload).expect("the payload is the renewal");
789        assert_eq!(inner.holder_id, "node-1");
790        assert_eq!(stub.resets.load(Ordering::SeqCst), 1, "reset after failure");
791    }
792
793    // The seam a consumer behind a `#[non_exhaustive]` enum needs: the transport
794    // is a runtime choice, so it must survive the trip through a trait object
795    // with the client's envelope and retry behavior intact.
796    #[tokio::test]
797    async fn given_a_boxed_transport_when_used_then_should_drive_the_typed_client() {
798        struct OwnedStub {
799            sent: StdMutex<Vec<(u32, Vec<u8>)>>,
800        }
801
802        impl ManagedKvTransport for OwnedStub {
803            async fn send(&self, code: u32, frame: Vec<u8>) -> Result<Vec<u8>, LaserError> {
804                self.sent.lock().expect("sent lock").push((code, frame));
805                ok_reply(KvOutcome::Leased {
806                    lease_token: 9,
807                    granted_ttl_micros: 30_000_000,
808                    position: position(),
809                })
810            }
811
812            async fn reset(&self) {}
813        }
814
815        let stub = Arc::new(OwnedStub {
816            sent: StdMutex::new(Vec::new()),
817        });
818        let transport: SharedKvTransport = Arc::clone(&stub) as SharedKvTransport;
819        let client = FencedLeaseClient::new(transport);
820        let op = client.prepare_acquire(&lease_request()).expect("prepares");
821        let lease = client.acquire(&op).await.expect("acquires");
822
823        assert_eq!(lease.token, 9);
824        assert_eq!(lease.granted_ttl, Duration::from_micros(30_000_000));
825        // The boxed hop preserves the exact framing: still one managed envelope
826        // under the prepared operation id.
827        let sent = stub.sent.lock().expect("sent lock");
828        assert_eq!(sent[0].0, AGDX_KV_LEASE_CODE);
829        let envelope: ManagedRequestEnvelope =
830            decode_named(&sent[0].1).expect("the frame is a managed envelope");
831        assert_eq!(envelope.operation_id, op.operation_id());
832    }
833
834    #[tokio::test]
835    async fn given_two_prepared_mutations_when_built_then_should_mint_distinct_operation_ids() {
836        let stub = StubTransport::answering(Vec::new());
837        let client = FencedLeaseClient::new(&stub);
838        let first = client.prepare_acquire(&lease_request()).expect("prepares");
839        let second = client.prepare_acquire(&lease_request()).expect("prepares");
840        assert_ne!(first.operation_id(), second.operation_id());
841    }
842
843    #[tokio::test]
844    async fn given_a_hung_transport_when_executing_then_should_reset_and_report_ambiguous() {
845        let stub = StubTransport::hanging();
846        let client = FencedLeaseClient::new(&stub).with_attempt_timeout(Duration::from_millis(100));
847        let op = client.prepare_acquire(&lease_request()).expect("prepares");
848        assert_eq!(
849            op.ambiguous_recovery(),
850            AmbiguousMutationRecovery::WaitForLeaseExpiry(Duration::from_secs(30))
851        );
852        let outcome = client.acquire(&op).await;
853        assert!(matches!(outcome, Err(LaserError::AmbiguousMutation(_))));
854        assert_eq!(stub.resets.load(Ordering::SeqCst), 1);
855    }
856
857    #[tokio::test]
858    async fn given_slow_readiness_when_send_fits_the_bound_then_should_not_report_ambiguous() {
859        let transport = SlowReadyTransport {
860            ready_delay: Duration::from_millis(125),
861            send_delay: Duration::from_millis(125),
862        };
863        let client =
864            FencedLeaseClient::new(&transport).with_attempt_timeout(Duration::from_millis(200));
865        let op = client.prepare_acquire(&lease_request()).expect("prepares");
866
867        let lease = client.acquire(&op).await.expect("acquires after readiness");
868
869        assert_eq!(lease.token, 9);
870    }
871
872    #[test]
873    fn given_default_client_when_built_then_should_allow_ten_second_attempts() {
874        let stub = StubTransport::answering(Vec::new());
875        let client = FencedLeaseClient::new(&stub);
876
877        assert_eq!(client.attempt_timeout, Duration::from_secs(10));
878    }
879
880    #[tokio::test]
881    async fn given_a_timed_out_mutation_when_reset_is_slow_then_should_wait_for_retirement() {
882        let transport = SlowResetTransport {
883            reset_finished: AtomicBool::new(false),
884        };
885        let client =
886            FencedLeaseClient::new(&transport).with_attempt_timeout(Duration::from_millis(1));
887        let op = client.prepare_acquire(&lease_request()).expect("prepares");
888        let started = tokio::time::Instant::now();
889
890        let outcome = client.acquire(&op).await;
891
892        assert!(matches!(outcome, Err(LaserError::AmbiguousMutation(_))));
893        assert!(transport.reset_finished.load(Ordering::SeqCst));
894        assert!(started.elapsed() >= Duration::from_millis(25));
895    }
896
897    #[tokio::test]
898    async fn given_a_closed_coordination_client_when_readied_then_should_not_reconnect() {
899        let client = FencedLeaseClient::connect_dedicated("not a connection string");
900        client.close().await;
901        client.close().await;
902        ManagedKvTransport::reset(&client.transport).await;
903        assert!(matches!(
904            ManagedKvTransport::ready(&client.transport).await,
905            Err(LaserError::Iggy(
906                crate::iggy::prelude::IggyError::ClientShutdown
907            ))
908        ));
909    }
910
911    #[tokio::test]
912    async fn given_a_permanent_setup_failure_when_executing_then_should_preserve_the_error() {
913        let unready = UnreadyTransport::default();
914        let client = FencedLeaseClient::new(&unready);
915        let op = client.prepare_acquire(&lease_request()).expect("prepares");
916        let outcome = client.acquire(&op).await;
917        assert!(matches!(outcome, Err(LaserError::Unsupported { .. })));
918    }
919
920    #[tokio::test]
921    async fn given_an_unsupported_deployment_when_called_repeatedly_then_should_keep_the_connection()
922     {
923        let unready = UnreadyTransport::default();
924        let client = FencedLeaseClient::new(&unready);
925        for _ in 0..3 {
926            let op = client.prepare_acquire(&lease_request()).expect("prepares");
927            assert!(matches!(
928                client.acquire(&op).await,
929                Err(LaserError::Unsupported { .. })
930            ));
931        }
932        assert_eq!(unready.resets.load(Ordering::SeqCst), 0);
933    }
934
935    #[tokio::test]
936    async fn given_a_permanent_send_failure_when_executing_then_should_preserve_the_error() {
937        let stub = StubTransport::answering(vec![Err(LaserError::unsupported_feature(
938            "kv",
939            "coordination",
940            "fenced leases are unavailable",
941        ))]);
942        let client = FencedLeaseClient::new(&stub);
943        let op = client.prepare_acquire(&lease_request()).expect("prepares");
944        let outcome = client.acquire(&op).await;
945        assert!(matches!(outcome, Err(LaserError::Unsupported { .. })));
946        assert_eq!(stub.resets.load(Ordering::SeqCst), 1);
947    }
948
949    #[tokio::test]
950    async fn given_an_authorization_send_failure_when_executing_then_should_preserve_the_error() {
951        let stub = StubTransport::answering(vec![Err(LaserError::Iggy(
952            crate::iggy::prelude::IggyError::Unauthorized,
953        ))]);
954        let client = FencedLeaseClient::new(&stub);
955        let op = client.prepare_acquire(&lease_request()).expect("prepares");
956        let outcome = client.acquire(&op).await;
957        assert!(matches!(
958            outcome,
959            Err(LaserError::Iggy(
960                crate::iggy::prelude::IggyError::Unauthorized
961            ))
962        ));
963        assert_eq!(stub.resets.load(Ordering::SeqCst), 1);
964    }
965
966    #[tokio::test]
967    async fn given_a_zero_attempt_timeout_when_executing_then_should_reject_without_sending() {
968        let stub = StubTransport::answering(Vec::new());
969        let client = FencedLeaseClient::new(&stub).with_attempt_timeout(Duration::ZERO);
970        let op = client.prepare_acquire(&lease_request()).expect("prepares");
971        let outcome = client.acquire(&op).await;
972        assert!(matches!(outcome, Err(LaserError::Invalid(_))));
973        assert!(stub.sent.lock().expect("sent lock").is_empty());
974    }
975
976    #[tokio::test]
977    async fn given_a_prepared_mutation_from_another_client_when_executed_then_should_reject() {
978        let first_stub = StubTransport::answering(Vec::new());
979        let second_stub = StubTransport::answering(Vec::new());
980        let first = FencedLeaseClient::new(&first_stub);
981        let second = FencedLeaseClient::new(&second_stub);
982        let op = first.prepare_acquire(&lease_request()).expect("prepares");
983        let outcome = second.acquire(&op).await;
984        assert!(matches!(outcome, Err(LaserError::Invalid(_))));
985        assert!(second_stub.sent.lock().expect("sent lock").is_empty());
986    }
987
988    #[tokio::test]
989    async fn given_an_unexpected_outcome_when_executing_then_should_fail_closed() {
990        // A `Leased` answer to a release must never be read as success.
991        let stub = StubTransport::answering(vec![ok_reply(KvOutcome::Leased {
992            lease_token: 7,
993            granted_ttl_micros: 30_000_000,
994            position: position(),
995        })]);
996        let client = FencedLeaseClient::new(&stub);
997        let release = KvRelease {
998            v: KV_LEASE_OP_VERSION,
999            namespace: "connectors.coordination".to_owned(),
1000            key: b"source-owner".to_vec(),
1001            lease_token: 7,
1002            holder_id: "node-1".to_owned(),
1003        };
1004        let op = client.prepare_release(&release).expect("prepares");
1005        let outcome = client.release(&op).await;
1006        assert!(matches!(outcome, Err(LaserError::Protocol(_))));
1007    }
1008
1009    #[tokio::test]
1010    async fn given_a_mismatched_prepared_mutation_when_executed_then_should_reject() {
1011        let stub = StubTransport::answering(Vec::new());
1012        let client = FencedLeaseClient::new(&stub);
1013        let op = client.prepare_acquire(&lease_request()).expect("prepares");
1014        let outcome = client.release(&op).await;
1015        assert!(matches!(outcome, Err(LaserError::Invalid(_))));
1016        assert!(stub.sent.lock().expect("sent lock").is_empty());
1017    }
1018
1019    #[tokio::test]
1020    async fn given_a_renewal_when_executed_then_should_return_the_same_token() {
1021        let stub = StubTransport::answering(vec![ok_reply(KvOutcome::Renewed {
1022            lease_token: 7,
1023            granted_ttl_micros: 25_000_000,
1024            position: position(),
1025        })]);
1026        let client = FencedLeaseClient::new(&stub);
1027        let renew = KvLeaseRenew {
1028            v: KV_LEASE_OP_VERSION,
1029            namespace: "connectors.coordination".to_owned(),
1030            key: b"source-owner".to_vec(),
1031            holder_id: "node-1".to_owned(),
1032            subject_user_id: Some(42),
1033            lease_token: 7,
1034            lease_ttl_micros: 30_000_000,
1035        };
1036        let op = client.prepare_renew(&renew).expect("prepares");
1037        let lease = client.renew(&op).await.expect("renews");
1038        assert_eq!(lease.token, 7);
1039        assert_eq!(lease.granted_ttl, Duration::from_micros(25_000_000));
1040    }
1041
1042    #[tokio::test]
1043    async fn given_a_lease_lost_reply_when_executing_then_should_surface_the_typed_error() {
1044        let stub = StubTransport::answering(vec![err_reply(KvError::LeaseLost)]);
1045        let client = FencedLeaseClient::new(&stub);
1046        let cas = KvCasFenced {
1047            v: KV_LEASE_OP_VERSION,
1048            namespace: "connectors.state".to_owned(),
1049            key: b"source_state/pg".to_vec(),
1050            value: b"cursor".to_vec(),
1051            expires_at_micros: None,
1052            expect: CasExpect::Match(4),
1053            fence_namespace: "connectors.coordination".to_owned(),
1054            fence_key: b"source-owner".to_vec(),
1055            fence_token: 7,
1056        };
1057        let op = client.prepare_cas_fenced(&cas).expect("prepares");
1058        let outcome = client.cas_fenced(&op).await;
1059        assert!(matches!(outcome, Err(LaserError::Kv(KvError::LeaseLost))));
1060        assert_eq!(
1061            stub.resets.load(Ordering::SeqCst),
1062            0,
1063            "a typed reply is not ambiguous"
1064        );
1065    }
1066
1067    #[tokio::test]
1068    async fn given_an_invalid_request_when_preparing_then_should_reject_before_any_send() {
1069        let stub = StubTransport::answering(Vec::new());
1070        let client = FencedLeaseClient::new(&stub);
1071        let stale_version = KvLease {
1072            v: KV_LEASE_OP_VERSION + 1,
1073            ..lease_request()
1074        };
1075        assert!(client.prepare_acquire(&stale_version).is_err());
1076        let no_holder = KvLease {
1077            holder_id: String::new(),
1078            ..lease_request()
1079        };
1080        assert!(client.prepare_acquire(&no_holder).is_err());
1081        assert!(stub.sent.lock().expect("sent lock").is_empty());
1082    }
1083
1084    #[tokio::test]
1085    async fn given_a_barriered_get_when_answered_stale_then_should_surface_stale_not_absent() {
1086        let stub = StubTransport::answering(vec![err_reply(KvError::Stale {
1087            required: position(),
1088        })]);
1089        let client = FencedLeaseClient::new(&stub);
1090        let request = KvGet {
1091            v: KV_OP_VERSION,
1092            namespace: "connectors.state".to_owned(),
1093            key: b"source_state/pg".to_vec(),
1094            if_none_match: None,
1095            min_position: Some(position()),
1096        };
1097        let outcome = client.get(&request).await;
1098        assert!(matches!(
1099            outcome,
1100            Err(LaserError::Kv(KvError::Stale { .. }))
1101        ));
1102        // The read rides unframed: no managed envelope, no operation id.
1103        let sent = stub.sent.lock().expect("sent lock");
1104        assert_eq!(sent[0].0, AGDX_KV_GET_CODE);
1105        let direct: KvGet = decode_named(&sent[0].1).expect("the frame is the bare get");
1106        assert_eq!(direct.min_position, Some(position()));
1107    }
1108}