frame-conv 0.2.0

Conversation patterns — request-response, subscription, pub/sub, and workflow over liminal
Documentation
//! The request-response pattern (`frame:conv-request@v1`, F-3a R2): typed
//! request with a caller-supplied deadline, closed outcome set, and defined
//! fates for late and duplicate replies.

use std::time::{Duration, Instant};

use serde::Serialize;
use serde::de::DeserializeOwned;

use crate::envelope::Envelope;
use crate::error::{CallError, PublishError};
use crate::id::{CorrelationId, MessageKind, PatternId};
use crate::outcome::{InboundRequest, IncomingRequest, PublishReceipt, RequestOutcome};
use crate::store::ResumeStore;

use super::pump::PumpStep;
use super::state::ConversationHandle;

impl<S: ResumeStore> ConversationHandle<S> {
    /// Sends a typed request and waits for exactly one outcome from the
    /// closed set: the correlated schema-validated reply, the caller's
    /// deadline elapsing, or a responder failure (F-3a R2).
    ///
    /// The deadline is caller-supplied per call — no default exists — and it
    /// is the ONLY thing that ends the request: elapsed IO quanta while
    /// waiting are benign re-arms and never change the outcome (constraint
    /// 3; the receive-cancel discipline). Correlation is by typed
    /// correlation identity, never delivery order (constraint 4). A reply
    /// arriving after the deadline surfaces as a typed late-reply anomaly on
    /// [`drain_anomalies`](Self::drain_anomalies); a second reply to the
    /// same correlation surfaces as a duplicate-reply anomaly; the first
    /// reply's delivery is unaffected (R6).
    ///
    /// # Errors
    ///
    /// Returns typed publish failures (schema-invalid refused before the
    /// wire; substrate refusals; transport loss), a typed
    /// schema-invalid-reply error, or a typed connection fate. A deadline
    /// elapse is NOT an error — it is the [`RequestOutcome::DeadlineElapsed`]
    /// outcome.
    pub fn request<Q: Serialize, R: DeserializeOwned>(
        &mut self,
        body: &Q,
        deadline: Duration,
    ) -> Result<RequestOutcome<R>, CallError> {
        let correlation = CorrelationId::mint();
        let envelope = Envelope::from_typed(
            PatternId::RequestResponse,
            correlation,
            MessageKind::Request,
            body,
        )
        .map_err(|refusal| PublishError::SchemaInvalid {
            detail: refusal.detail,
        })?;
        let bytes = envelope
            .encode()
            .map_err(|refusal| PublishError::Protocol {
                detail: refusal.detail,
            })?;
        let receipt = self.admit(bytes)?;
        self.exchanges.open(correlation);
        let started = Instant::now();
        loop {
            if let Some(held) = self.replies.remove(&correlation) {
                match held.envelope.decode_payload::<R>() {
                    Ok(reply) => {
                        return Ok(RequestOutcome::Replied {
                            reply,
                            responder: held.responder,
                            seq: held.seq,
                        });
                    }
                    Err(refusal) => {
                        return Err(CallError::ReplySchemaInvalid {
                            correlation,
                            detail: refusal.detail,
                        });
                    }
                }
            }
            if let Some(note) = self
                .deaths
                .iter()
                .find(|note| note.seq > receipt.seq)
                .cloned()
            {
                // A peer died after this request was admitted. Pin: after a
                // responder failure, a reply to this exchange classifies as
                // late. (No live producer exists at liminal 0.3.0 — ASK-4.)
                self.exchanges.close_elapsed(correlation);
                return Ok(RequestOutcome::ResponderFailed {
                    peer: note.peer,
                    failure: note.failure,
                    seq: note.seq,
                });
            }
            if started.elapsed() >= deadline {
                self.exchanges.close_elapsed(correlation);
                return Ok(RequestOutcome::DeadlineElapsed { deadline });
            }
            if let Err(detail) = self.pump_step() {
                return Err(CallError::ConnectionLost { detail });
            }
        }
    }

    /// Waits up to `wait` for the next inbound request. `Ok(None)` is a
    /// benign quiet wait — never an error, never a protocol outcome (the
    /// assertion-8 pin at this crate's surface). A schema-invalid request
    /// surfaces typed as [`InboundRequest::SchemaInvalid`], never dropped.
    ///
    /// The effective wait granularity is the substrate's IO quantum
    /// (hardcoded 5 s at published liminal 0.3.0 — upstream ASK-2): a wait
    /// smaller than one quantum may still block for one quantum.
    ///
    /// # Errors
    ///
    /// Returns a typed connection fate.
    pub fn next_request<Q: DeserializeOwned>(
        &mut self,
        wait: Duration,
    ) -> Result<Option<InboundRequest<Q>>, CallError> {
        let started = Instant::now();
        loop {
            if let Some(held) = self.requests_inbox.pop_front() {
                let correlation = held.envelope.correlation;
                return Ok(Some(match held.envelope.decode_payload::<Q>() {
                    Ok(body) => InboundRequest::Valid(IncomingRequest {
                        body,
                        correlation,
                        requester: held.requester,
                        seq: held.seq,
                    }),
                    Err(refusal) => InboundRequest::SchemaInvalid {
                        correlation,
                        requester: held.requester,
                        seq: held.seq,
                        detail: refusal.detail,
                    },
                }));
            }
            if started.elapsed() >= wait {
                return Ok(None);
            }
            match self.pump_step() {
                Ok(PumpStep::Classified | PumpStep::Quiet) => {}
                Err(detail) => return Err(CallError::ConnectionLost { detail }),
            }
        }
    }

    /// Publishes the typed reply to a received request, correlated to the
    /// request's exchange. Schema-invalid replies are refused typed BEFORE
    /// the wire (F-3a R1).
    ///
    /// # Errors
    ///
    /// Returns typed publish failures.
    pub fn reply<R: Serialize>(
        &mut self,
        correlation: CorrelationId,
        body: &R,
    ) -> Result<PublishReceipt, PublishError> {
        let envelope = Envelope::from_typed(
            PatternId::RequestResponse,
            correlation,
            MessageKind::Reply,
            body,
        )
        .map_err(|refusal| PublishError::SchemaInvalid {
            detail: refusal.detail,
        })?;
        let bytes = envelope
            .encode()
            .map_err(|refusal| PublishError::Protocol {
                detail: refusal.detail,
            })?;
        self.admit(bytes)
    }
}