Skip to main content

onlyne_client/session/dispatch/
outbound.rs

1use super::*;
2
3use super::env::{AGENT, REQUEST_TIMEOUT};
4use super::projection::with_cluster;
5use super::state::{DispatchInner, DispatchState};
6
7/// Write one ack into the durable intent queue.
8pub(super) fn store_ack(inner: &DispatchInner, mut ack: AckArgs) {
9    if ack.op_id.is_none() {
10        ack.op_id = Some(onlyne_proto::new_op_id());
11    }
12    let Some(op_id) = ack.op_id.clone() else {
13        return;
14    };
15    match serde_json::to_value(ClientOp::Ack(ack)) {
16        Ok(value) => {
17            if let Err(error) = inner.store.enqueue_intent(&op_id, &value) {
18                tracing::warn!(error = %error, "settled ack was not stored");
19            }
20        }
21        Err(error) => tracing::warn!(error = %error, "settled ack did not serialize"),
22    }
23}
24
25/// Queue one envelope into the durable intent table while the dispatch lock is
26/// already held. The `op_id` rule and the validation are `enqueue_outbound`'s.
27pub(super) fn queue_outbound_locked(
28    inner: &mut DispatchInner,
29    envelope: &Envelope,
30) -> Result<String> {
31    let mut stamped = envelope.clone();
32    let op_id = stamp_op_id(&mut stamped);
33    stamped
34        .validate()
35        .map_err(|error| anyhow!(error.to_string()))?;
36    inner
37        .store
38        .enqueue_intent(&op_id, &serde_json::to_value(&stamped)?)?;
39    Ok(op_id)
40}
41
42/// Hand one envelope to the live link, or to the intent queue when it is down.
43pub(super) async fn transport_envelope(state: &DispatchState, envelope: &Envelope) -> Result<()> {
44    let op = ClientOp::Send(Box::new(envelope.clone()));
45    let outbox = { state.inner.lock().outbox.clone() };
46    match outbox {
47        Some(outbox) => match outbox.send(op).await {
48            Ok(()) => Ok(()),
49            Err(error) => {
50                tracing::warn!(error = %error, "completion fell back to the intent queue");
51                state.enqueue_outbound(envelope).map(|_| ())
52            }
53        },
54        None => state.enqueue_outbound(envelope).map(|_| ()),
55    }
56}
57
58/// One authenticated server link.
59///
60/// The first five methods are the whole transport surface the runloop uses, so
61/// a change of transport touches this struct alone. The remaining two read the
62/// supervision state of the connection the handle keeps across redials.
63#[derive(Clone)]
64pub struct ClientLink {
65    handle: ClientConn,
66    welcome: Arc<Welcome>,
67    hello: HandshakeArgs,
68}
69
70impl ClientLink {
71    /// Dial, verify the certificate pin, sign the server challenge, then read
72    /// the role slice with `hello`.
73    pub async fn connect(init: &ClientInit, live_tasks: Vec<String>) -> Result<Self, NetError> {
74        let keypair = KeyPair::load(&init.key_path)?;
75        let settings = ConnSettings {
76            agent: AGENT.to_string(),
77            version: env!("CARGO_PKG_VERSION").to_string(),
78            ..ConnSettings::new(PROTOCOL_VERSION)
79        };
80        let handle: ClientConn =
81            dial(&init.server, &keypair, &init.cert_pin, &init.role, settings).await?;
82        let hello = HandshakeArgs {
83            protocol: PROTOCOL_VERSION,
84            role: init.role.clone(),
85            key: keypair.public_str(),
86            signature: String::new(),
87            agent: AGENT.to_string(),
88            version: env!("CARGO_PKG_VERSION").to_string(),
89            aggregate: false,
90            live_tasks: Vec::new(),
91        };
92        let body = handle
93            .request(
94                Frame::req(
95                    String::new(),
96                    ClientOp::Hello(hello_with_live_tasks(&hello, live_tasks)),
97                ),
98                REQUEST_TIMEOUT,
99            )
100            .await?;
101        if !body.ok {
102            let error = body.error.clone().unwrap_or(onlyne_proto::ErrorPayload {
103                code: onlyne_proto::ErrorCode::Internal,
104                message: "hello refused".to_string(),
105                field: None,
106            });
107            return Err(NetError::Rejected {
108                code: wire_code(error.code),
109                message: error.message,
110            });
111        }
112        let data = body.data().cloned().ok_or(NetError::BadFrame)?;
113        let welcome: Welcome = serde_json::from_value(data).map_err(|_| NetError::BadFrame)?;
114        Ok(Self {
115            handle,
116            welcome: Arc::new(welcome),
117            hello,
118        })
119    }
120
121    /// Send the routed `hello` again on the connection this link now holds.
122    ///
123    /// The net layer redials on its own, and a fresh connection carries no role
124    /// binding until this frame lands, so a caller replays it whenever readiness
125    /// returns (plan §7 line 310).
126    pub async fn authenticate(&self, live_tasks: Vec<String>) -> Result<(), NetError> {
127        let body = self
128            .handle
129            .request(
130                Frame::req(
131                    String::new(),
132                    ClientOp::Hello(hello_with_live_tasks(&self.hello, live_tasks)),
133                ),
134                REQUEST_TIMEOUT,
135            )
136            .await?;
137        if !body.ok {
138            let error = body.error.clone().unwrap_or(onlyne_proto::ErrorPayload {
139                code: onlyne_proto::ErrorCode::Internal,
140                message: "hello refused".to_string(),
141                field: None,
142            });
143            return Err(NetError::Rejected {
144                code: wire_code(error.code),
145                message: error.message,
146            });
147        }
148        Ok(())
149    }
150
151    /// Role slice the server bound this link to.
152    pub fn welcome(&self) -> &Welcome {
153        &self.welcome
154    }
155
156    /// Send one request frame and answer with its body. A refusal from the
157    /// server arrives inside the body, which keeps the retry decision in the
158    /// intent machine.
159    pub async fn request(&self, op: ClientOp) -> Result<ResBody, NetError> {
160        self.handle
161            .request(Frame::req(String::new(), op), REQUEST_TIMEOUT)
162            .await
163    }
164
165    /// Clone the server observation stream.
166    pub fn events(&self) -> broadcast::Receiver<Frame<ClientOp>> {
167        self.handle.events()
168    }
169
170    /// Send `bye` and drain in-flight requests.
171    pub async fn close(&self) -> Result<(), NetError> {
172        self.handle.close().await
173    }
174
175    /// Liveness of the connection behind this link.
176    pub fn readiness(&self) -> ConnReadiness {
177        self.handle.readiness()
178    }
179    /// Reason the supervisor stopped redialing.
180    pub async fn failure(&self) -> Option<NetError> {
181        self.handle.failure().await
182    }
183}
184
185/// Stamp dispatch live-slot task ids onto a hello skeleton.
186///
187/// Slots exist from assign until release. A fresh process has none, so hello
188/// sends an empty list and the server requeues. A live client whose link
189/// flaps still holds its slots, so those rows stay in_flight.
190pub(crate) fn hello_with_live_tasks(
191    hello: &HandshakeArgs,
192    live_tasks: Vec<String>,
193) -> HandshakeArgs {
194    let mut hello = hello.clone();
195    hello.live_tasks = live_tasks;
196    hello
197}
198
199/// Wire code of a refusal, in the snake_case spelling both sides share.
200fn wire_code(code: onlyne_proto::ErrorCode) -> String {
201    serde_json::to_value(code)
202        .ok()
203        .and_then(|value| value.as_str().map(str::to_string))
204        .unwrap_or_else(|| "internal".to_string())
205}
206
207/// Where lifecycle frames leave the dispatcher.
208///
209/// `send` completes once the frame is on the wire. The ready report awaits this
210/// before the payload reaches the agent, which is the causal order §6 fixes.
211pub trait Outbox: Send + Sync {
212    fn send(&self, op: ClientOp)
213    -> Pin<Box<dyn Future<Output = Result<(), NetError>> + Send + '_>>;
214
215    /// One request round trip, for a caller that needs the server's answer
216    /// rather than a queued frame: the local CLI reports the verdict a send got.
217    fn request(
218        &self,
219        op: ClientOp,
220    ) -> Pin<Box<dyn Future<Output = Result<ResBody, NetError>> + Send + '_>>;
221}
222
223impl Outbox for ClientLink {
224    fn send(
225        &self,
226        op: ClientOp,
227    ) -> Pin<Box<dyn Future<Output = Result<(), NetError>> + Send + '_>> {
228        Box::pin(async move { self.request(op).await.map(|_| ()) })
229    }
230
231    fn request(
232        &self,
233        op: ClientOp,
234    ) -> Pin<Box<dyn Future<Output = Result<ResBody, NetError>> + Send + '_>> {
235        Box::pin(async move { ClientLink::request(self, op).await })
236    }
237}
238
239/// Deliver one lifecycle frame, and queue it durably when the link is down.
240///
241/// §6 line 289: a running session reaches its terminal state while the outbound
242/// work waits in `client.db` intents for the flusher.
243///
244/// The frame that could not leave says nothing about the link, so the shared
245/// accept gate is left exactly where the runloop put it. A send fails with the
246/// link still `Ready` — the request deadline belongs to the caller, and
247/// `onlyne_net::conn` records that "the silent peer keeps the link up; only this
248/// call gave up" — and dropping the flag here latched it: `watch_readiness` only
249/// re-arms `accept_new` on a readiness transition, so the pull loop stopped
250/// draining the role's inbox for the life of that link while the work sat queued
251/// on the server, and the next delivery to arrive found a client that refused
252/// new work. The connection's own state is the flag's only author
253/// (`runloop::link`).
254pub async fn send_frame(state: &DispatchState, op: ClientOp) -> Result<()> {
255    let op = match op {
256        ClientOp::Report(report) => ClientOp::Report(with_cluster(state, report)),
257        other => other,
258    };
259    if let Some(outbox) = state.outbox() {
260        if outbox.send(op.clone()).await.is_ok() {
261            return Ok(());
262        }
263    }
264    state.enqueue_op(&op)?;
265    Ok(())
266}
267
268impl DispatchState {
269    /// Queue an outbound envelope before its first write and answer its op_id.
270    ///
271    /// The queue keys every row by an `op_id`, and the proto requires that key
272    /// only for the non-note kinds, so a note that arrives without one gets a
273    /// fresh client-minted id here: the row is keyed and what it replays is the
274    /// whole stamped envelope. A non-note keeps the id it brought, so a
275    /// re-delivered task still dedups on its original one.
276    pub fn enqueue_outbound(&self, envelope: &Envelope) -> Result<String> {
277        queue_outbound_locked(&mut self.inner.lock(), envelope)
278    }
279
280    /// The flag the runloop and the dispatcher share.
281    pub fn accept_new(&self) -> Arc<AtomicBool> {
282        self.inner.lock().accept_new.clone()
283    }
284
285    /// Whether the role holds a ready server link.
286    pub fn link_up(&self) -> bool {
287        self.inner.lock().link_up.load(Ordering::SeqCst)
288    }
289
290    /// Record that the server link came up or went down.
291    pub fn set_link_up(&self, up: bool) {
292        self.inner.lock().link_up.store(up, Ordering::SeqCst);
293    }
294
295    /// Aggregate name this role supervises, empty for a plain role.
296    pub fn cluster_ref(&self) -> String {
297        self.inner.lock().cluster_ref.clone()
298    }
299
300    /// Record the server's topology name, read from `welcome.cluster`.
301    ///
302    /// Each spawned session carries it as `ONLYNE_CLUSTER`, which is how a host
303    /// backend (herdr) addresses the tree it splits panes into. The runloop calls
304    /// this on every welcome, so a server that reloads under a new name is
305    /// followed by the sessions spawned after that point.
306    pub fn set_topology(&self, cluster: &str) {
307        self.inner.lock().topology = cluster.trim().to_string();
308    }
309
310    /// The topology name recorded from `welcome`, empty before the first welcome.
311    pub fn topology(&self) -> String {
312        self.inner.lock().topology.clone()
313    }
314
315    /// Record the aggregate name once, so every report keeps the same value
316    /// across a reconnect.
317    pub fn set_cluster_ref(&self, aggregate: impl Into<String>) {
318        self.inner.lock().cluster_ref = aggregate.into();
319    }
320
321    /// Ask the server one question over the live link.
322    ///
323    /// Err means the link is down, never a refusal: a refusal arrives as an
324    /// `Ok` body carrying `ok: false`, which is what the local CLI shows.
325    pub async fn request(&self, op: ClientOp) -> Result<ResBody, NetError> {
326        let outbox = { self.inner.lock().outbox.clone() };
327        let Some(outbox) = outbox else {
328            return Err(NetError::NotReady);
329        };
330        outbox.request(op).await
331    }
332
333    /// Install the live link as the outbound path.
334    pub fn attach_outbox(&self, outbox: Arc<dyn Outbox>) {
335        self.inner.lock().outbox = Some(outbox);
336    }
337
338    /// Remove the outbound path; lifecycle frames then queue as intents.
339    pub fn detach_outbox(&self) {
340        self.inner.lock().outbox = None;
341    }
342
343    fn outbox(&self) -> Option<Arc<dyn Outbox>> {
344        self.inner.lock().outbox.clone()
345    }
346
347    /// Queue one client op in the durable intent table and answer its op_id.
348    pub fn enqueue_op(&self, op: &ClientOp) -> Result<String> {
349        let op_id = onlyne_proto::new_id();
350        self.inner
351            .lock()
352            .store
353            .enqueue_intent(&op_id, &serde_json::to_value(op)?)?;
354        Ok(op_id)
355    }
356}