Skip to main content

aimdb_client/
engine.rs

1//! Engine-based AimX client.
2//!
3//! The client rides the shared session engine: a `UdsDialer` (unlinked — it
4//! exists only behind `transport-uds`) + the symmetric
5//! [`AimxCodec`] drive [`run_client`], which owns the wire, the request-id
6//! demux, and (optionally) reconnect. The public surface is the cheap-clone
7//! [`ClientHandle`] plus typed convenience wrappers and per-subscription
8//! [`futures::Stream`]s.
9//!
10//! `run_client` is itself spawn-free (it returns a future for a runner to
11//! drive); this convenience layer is a *client application*, so it drives the
12//! engine on a `tokio::spawn`ed task held by [`AimxConnection`]. Dropping the
13//! connection drops the handle, which stops the engine gracefully.
14
15use std::sync::Arc;
16use std::time::Duration;
17
18use futures::StreamExt;
19use serde::{Deserialize, Serialize};
20use serde_json::json;
21use tokio::task::JoinHandle;
22use tokio::time::timeout;
23
24use aimdb_core::session::aimx::AimxCodec;
25use aimdb_core::session::{
26    run_client, BoxStream, ClientConfig, ClientHandle, Dialer, Payload, RpcError,
27};
28use aimdb_tokio_adapter::TokioAdapter;
29
30use crate::error::{ClientError, ClientResult};
31use crate::protocol::{version_compatible, RecordMetadata, WelcomeMessage, PROTOCOL_VERSION};
32
33/// Default deadline for the connect handshake (dial + `hello`/Welcome). Bounds
34/// the case where a peer accepts the socket but never replies — the engine has
35/// no handshake timeout of its own, so the wait would otherwise be unbounded.
36pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
37
38/// Response from a `record.drain` call: the values accumulated since the
39/// previous drain for this connection's per-record cursor.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct DrainResponse {
42    /// Echo of the queried record name.
43    pub record_name: String,
44    /// Chronologically ordered values (raw JSON, as written by the producer).
45    pub values: Vec<serde_json::Value>,
46    /// Number of values returned.
47    pub count: usize,
48}
49
50/// One update from a subscription: the record value, plus the delivery metadata
51/// the engine tracks alongside it.
52///
53/// One inbound frame yields exactly one of these, so [`skipped`](Self::skipped)
54/// and [`snapshot_end`](Self::snapshot_end) always describe the frame they
55/// arrive on.
56#[derive(Debug, Clone, PartialEq)]
57pub struct RecordUpdate {
58    /// Concrete record that fired. `Some` on wildcard subscriptions, which fan
59    /// in many records under one subscription; `None` when the server left the
60    /// event untagged (exact-topic subscribe).
61    pub topic: Option<String>,
62    /// The decoded record value, or `None` if the payload did not decode — a
63    /// malformed peer, since the server serializes record values as JSON.
64    ///
65    /// An undecodable frame is still delivered, so that
66    /// [`skipped`](Self::skipped) and [`snapshot_end`](Self::snapshot_end) stay
67    /// attached to the frame they describe, and so one bad record cannot end a
68    /// wildcard stream that fans in many.
69    pub value: Option<serde_json::Value>,
70    /// Updates lost between the previous delivered update and this one — `0`
71    /// when delivery was lossless. Non-zero means the server-side buffer
72    /// overran a slow consumer, so the value stream has a hole immediately
73    /// before this item.
74    pub skipped: u64,
75    /// Final update of a late-join snapshot burst: every update after it is a
76    /// live event. A burst that overran the sink carries its whole loss in
77    /// [`skipped`](Self::skipped) here — the engine reserves a slot for this
78    /// update, so an overrun cannot cost you the marker.
79    ///
80    /// **Its absence is not a signal**, and must never be waited on. The marker
81    /// rides the last snapshot's frame, so a burst that produced none never
82    /// sets it: nothing matched, nothing has a value yet, an exact-topic
83    /// subscribe, or a tail that failed to encode. Only the last of those is a
84    /// real loss, and [`skipped`](Self::skipped) reports it.
85    pub snapshot_end: bool,
86}
87
88impl RecordUpdate {
89    /// Whether updates were lost immediately before this one.
90    pub fn has_gap(&self) -> bool {
91        self.skipped > 0
92    }
93}
94
95/// A live connection to an AimDB instance over the shared session engine.
96///
97/// Holds the cheap-clone [`ClientHandle`] (use [`handle`](Self::handle) to issue
98/// raw `call`/`subscribe`/`write`) and the driven engine task. Typed wrappers
99/// cover the common AimX methods.
100pub struct AimxConnection {
101    handle: ClientHandle,
102    engine: JoinHandle<()>,
103    server_info: WelcomeMessage,
104}
105
106impl AimxConnection {
107    /// Resolve `endpoint` to a transport, start the engine, and complete the
108    /// `hello` handshake, bounded by [`DEFAULT_CONNECT_TIMEOUT`].
109    ///
110    /// `endpoint` is a `scheme://` URL — `unix://PATH` / `uds://PATH`,
111    /// `serial://DEVICE?baud=N` — or a bare path (the `unix://` shorthand). The
112    /// scheme picks the transport via [`crate::endpoint::dial`]; an unknown scheme
113    /// (or one whose transport isn't compiled in) fails before the engine starts.
114    pub async fn connect(endpoint: &str) -> ClientResult<Self> {
115        Self::connect_with_timeout(endpoint, DEFAULT_CONNECT_TIMEOUT).await
116    }
117
118    /// Like [`connect`](Self::connect), but with an explicit handshake deadline.
119    pub async fn connect_with_timeout(
120        endpoint: &str,
121        connect_timeout: Duration,
122    ) -> ClientResult<Self> {
123        let dialer = crate::endpoint::dial(endpoint)?;
124        Self::spin_up(dialer, endpoint, connect_timeout).await
125    }
126
127    /// Connect over an explicit [`Dialer`], bypassing endpoint resolution — for
128    /// transports the resolver doesn't cover, or a pre-built dialer.
129    pub async fn connect_over(dialer: impl Dialer + 'static) -> ClientResult<Self> {
130        Self::connect_over_with_timeout(dialer, DEFAULT_CONNECT_TIMEOUT).await
131    }
132
133    /// Like [`connect_over`](Self::connect_over), with an explicit handshake deadline.
134    pub async fn connect_over_with_timeout(
135        dialer: impl Dialer + 'static,
136        connect_timeout: Duration,
137    ) -> ClientResult<Self> {
138        Self::spin_up(dialer, "remote peer", connect_timeout).await
139    }
140
141    /// Spin up the engine over `dialer` and complete the `hello` handshake.
142    ///
143    /// The handshake is a normal RPC (`call("hello", …) -> Welcome`) rather than
144    /// a privileged frame — the reshaped wire's deliberate simplification. The
145    /// `connect_timeout` covers the whole handshake (dial *and* the Welcome
146    /// exchange), so a silent or unresponsive peer can't block the caller; on any
147    /// failure the engine task is aborted so it doesn't linger blocked. `label`
148    /// names the endpoint in error messages.
149    async fn spin_up(
150        dialer: impl Dialer + 'static,
151        label: &str,
152        connect_timeout: Duration,
153    ) -> ClientResult<Self> {
154        let config = ClientConfig {
155            reconnect: false,
156            sends_hello: false,
157            ..ClientConfig::default()
158        };
159        let (handle, engine_fut) = run_client(dialer, AimxCodec, config, Arc::new(TokioAdapter));
160        let engine = tokio::spawn(engine_fut);
161
162        let server_info = async {
163            let hello = json!({ "client": "aimdb-client", "version": PROTOCOL_VERSION });
164            let reply = timeout(connect_timeout, handle.call("hello", to_payload(&hello)?))
165                .await
166                .map_err(|_| ClientError::connection_failed(label, "handshake timed out"))?
167                .map_err(|e| match e {
168                    // The server refused our declared version — report it as
169                    // such, not as an unreachable-engine failure.
170                    RpcError::VersionMismatch => ClientError::connection_failed(
171                        label,
172                        format!(
173                            "protocol version mismatch: server rejected client version {PROTOCOL_VERSION}"
174                        ),
175                    ),
176                    _ => ClientError::connection_failed(
177                        label,
178                        "handshake failed (engine could not reach server)",
179                    ),
180                })?;
181            let welcome = from_payload::<WelcomeMessage>(&reply)?;
182            // Reverse gate: a pre-3.x server ignores our `version` and completes
183            // the handshake with an old-shaped `welcome`. Detect that here so a
184            // new client fails fast instead of tripping over the old reply shapes.
185            if !version_compatible(&welcome.version) {
186                return Err(ClientError::connection_failed(
187                    label,
188                    format!(
189                        "protocol version mismatch: server speaks {}, client speaks {PROTOCOL_VERSION}",
190                        welcome.version
191                    ),
192                ));
193            }
194            Ok(welcome)
195        }
196        .await;
197
198        match server_info {
199            Ok(server_info) => Ok(Self {
200                handle,
201                engine,
202                server_info,
203            }),
204            Err(e) => {
205                // Don't leave the engine task blocked on a stalled dial/connection.
206                engine.abort();
207                Err(e)
208            }
209        }
210    }
211
212    /// The raw engine handle — `call` / `subscribe` / `write` for methods the
213    /// typed wrappers below don't cover.
214    pub fn handle(&self) -> &ClientHandle {
215        &self.handle
216    }
217
218    /// The server's `Welcome` (permissions, writable records) from the handshake.
219    pub fn server_info(&self) -> &WelcomeMessage {
220        &self.server_info
221    }
222
223    /// List all registered records.
224    pub async fn list_records(&self) -> ClientResult<Vec<RecordMetadata>> {
225        let reply = self.call("record.list", null_payload()).await?;
226        Ok(serde_json::from_slice(&reply)?)
227    }
228
229    /// Get a record's current value.
230    ///
231    /// For a `SingleLatest`/state record this is a non-destructive read. A ring
232    /// (`SpmcRing`) has no canonical latest, so the server returns the most recent
233    /// value from this connection's drain cursor — which **advances that cursor**
234    /// (interleaving with [`drain_record`](Self::drain_record)) and is empty until
235    /// the ring produces a value after the connection first reads it. Prefer
236    /// [`drain_record`](Self::drain_record) for ring/history records.
237    pub async fn get_record(&self, name: &str) -> ClientResult<serde_json::Value> {
238        let reply = self
239            .call("record.get", to_payload(&json!({ "name": name }))?)
240            .await?;
241        Ok(serde_json::from_slice(&reply)?)
242    }
243
244    /// Set a record's value (RPC; awaits the server's reply).
245    pub async fn set_record(
246        &self,
247        name: &str,
248        value: serde_json::Value,
249    ) -> ClientResult<serde_json::Value> {
250        let reply = self
251            .call(
252                "record.set",
253                to_payload(&json!({ "name": name, "value": value }))?,
254            )
255            .await?;
256        Ok(serde_json::from_slice(&reply)?)
257    }
258
259    /// Subscribe to a record or topic pattern.
260    ///
261    /// Yields the full [`RecordUpdate`] — value, per-record topic,
262    /// [`skipped`](RecordUpdate::skipped) gap count and
263    /// [`snapshot_end`](RecordUpdate::snapshot_end) burst marker — so a consumer
264    /// that cares about completeness (a watcher, a mirror, an archiver) can see
265    /// what the engine dropped. One that only wants values drops the rest:
266    ///
267    /// ```ignore
268    /// let values = conn.subscribe("temp.vienna")?
269    ///     .filter_map(|r| futures::future::ready(r.ok().and_then(|u| u.value)));
270    /// ```
271    ///
272    /// Wildcards (`#`, `*`) are supported: one subscription fans in every
273    /// matching record, each update tagged with the record that fired. The
274    /// engine routes events back by the request id it owns, so there is no
275    /// `subscription_id` to track.
276    ///
277    /// A refused subscription — denied, or past the server's per-connection
278    /// limit — yields one `Err` item and then ends, so it stays distinguishable
279    /// from a clean end (disconnect, closed record), which is simply `None`.
280    /// Dropping the stream stops local delivery.
281    pub fn subscribe(
282        &self,
283        pattern: &str,
284    ) -> ClientResult<BoxStream<'static, Result<RecordUpdate, ClientError>>> {
285        let raw = self.handle.subscribe(pattern).map_err(rpc_err)?;
286        Ok(decode_updates(raw))
287    }
288
289    /// Fire-and-forget write to a record (no reply; routes through the server's
290    /// producer/arbiter path — single-writer-per-key stays intact).
291    pub fn write_record(&self, name: &str, value: serde_json::Value) -> ClientResult<()> {
292        self.handle
293            .write(name, to_payload(&json!({ "value": value }))?)
294            .map_err(rpc_err)
295    }
296
297    /// Drain all values accumulated since the previous drain of `name` (a
298    /// destructive read against this connection's per-record cursor).
299    pub async fn drain_record(&self, name: &str) -> ClientResult<DrainResponse> {
300        let reply = self
301            .call("record.drain", to_payload(&json!({ "name": name }))?)
302            .await?;
303        Ok(serde_json::from_slice(&reply)?)
304    }
305
306    /// Drain at most `limit` values from `name`.
307    pub async fn drain_record_with_limit(
308        &self,
309        name: &str,
310        limit: u32,
311    ) -> ClientResult<DrainResponse> {
312        let reply = self
313            .call(
314                "record.drain",
315                to_payload(&json!({ "name": name, "limit": limit }))?,
316            )
317            .await?;
318        Ok(serde_json::from_slice(&reply)?)
319    }
320
321    /// Run a persistence query (requires the server's `with_persistence()`).
322    pub async fn query(&self, params: serde_json::Value) -> ClientResult<serde_json::Value> {
323        let reply = self.call("record.query", to_payload(&params)?).await?;
324        Ok(serde_json::from_slice(&reply)?)
325    }
326
327    /// All nodes in the dependency graph.
328    pub async fn graph_nodes(&self) -> ClientResult<Vec<serde_json::Value>> {
329        let reply = self.call("graph.nodes", null_payload()).await?;
330        Ok(serde_json::from_slice(&reply)?)
331    }
332
333    /// All edges in the dependency graph.
334    pub async fn graph_edges(&self) -> ClientResult<Vec<serde_json::Value>> {
335        let reply = self.call("graph.edges", null_payload()).await?;
336        Ok(serde_json::from_slice(&reply)?)
337    }
338
339    /// Record keys in topological order.
340    pub async fn graph_topo_order(&self) -> ClientResult<Vec<String>> {
341        let reply = self.call("graph.topo_order", null_payload()).await?;
342        Ok(serde_json::from_slice(&reply)?)
343    }
344
345    /// Reset stage-profiling counters (server built with `profiling`; needs write
346    /// permission).
347    pub async fn reset_stage_profiling(&self) -> ClientResult<serde_json::Value> {
348        let reply = self.call("profiling.reset", null_payload()).await?;
349        Ok(serde_json::from_slice(&reply)?)
350    }
351
352    /// Reset buffer-metrics counters (server built with `metrics`; needs write
353    /// permission).
354    pub async fn reset_buffer_metrics(&self) -> ClientResult<serde_json::Value> {
355        let reply = self.call("buffer_metrics.reset", null_payload()).await?;
356        Ok(serde_json::from_slice(&reply)?)
357    }
358
359    /// Issue a raw RPC and map a transport/engine failure to [`ClientError`].
360    async fn call(&self, method: &str, params: Payload) -> ClientResult<Payload> {
361        self.handle.call(method, params).await.map_err(rpc_err)
362    }
363}
364
365impl Drop for AimxConnection {
366    fn drop(&mut self) {
367        // Dropping `handle` already stops the engine; abort is just promptness.
368        self.engine.abort();
369    }
370}
371
372/// Decode a raw engine subscription stream into [`RecordUpdate`]s.
373///
374/// One inbound frame in, one stream item out: an undecodable payload rides
375/// through as `value: None`, keeping `skipped` and `snapshot_end` attached to
376/// the frame they describe. A snapshot burst can close without any event
377/// following it, so metadata held back for a later update may never be
378/// delivered at all.
379///
380/// A terminal rejection yields one `Err` item and then ends the stream. The
381/// engine drops the sink after sending one, so the raw stream ends there
382/// anyway; the latch holds the contract regardless.
383fn decode_updates(
384    raw: BoxStream<'static, Result<aimdb_core::session::SubUpdate, RpcError>>,
385) -> BoxStream<'static, Result<RecordUpdate, ClientError>> {
386    let decoded = raw.scan(false, |terminated, item| {
387        if *terminated {
388            return futures::future::ready(None);
389        }
390        let out = match item {
391            Ok(u) => Ok(RecordUpdate {
392                topic: u.topic.as_deref().map(String::from),
393                // A decode failure means a malformed peer, since the server
394                // serializes record values as JSON — reported per item rather
395                // than fatal, so one bad record cannot end a wildcard stream.
396                value: serde_json::from_slice(&u.data).ok(),
397                skipped: u.skipped,
398                snapshot_end: u.snapshot_end,
399            }),
400            Err(e) => {
401                *terminated = true;
402                Err(rpc_err(e))
403            }
404        };
405        futures::future::ready(Some(out))
406    });
407    Box::pin(decoded)
408}
409
410/// Serialize a value into a record-value [`Payload`].
411fn to_payload<T: Serialize>(value: &T) -> ClientResult<Payload> {
412    Ok(Payload::from(serde_json::to_vec(value)?.as_slice()))
413}
414
415/// The JSON literal `null` as a [`Payload`] — for methods that take no params.
416fn null_payload() -> Payload {
417    Payload::from(&b"null"[..])
418}
419
420/// Decode a [`Payload`] into a typed value.
421fn from_payload<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> ClientResult<T> {
422    Ok(serde_json::from_slice(bytes)?)
423}
424
425/// Map an engine [`RpcError`] onto a [`ClientError`].
426fn rpc_err(e: RpcError) -> ClientError {
427    match e {
428        RpcError::NotFound => {
429            ClientError::server_error("not_found", "method or record not found", None)
430        }
431        RpcError::Denied => ClientError::server_error("denied", "permission denied", None),
432        // `Internal` today, plus any future non-exhaustive variant.
433        _ => ClientError::server_error("internal", "engine/transport failure", None),
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use aimdb_core::session::SubUpdate;
441    use serde_json::json;
442
443    /// Build a raw engine stream from a fixed list of subscription items.
444    fn raw_stream(
445        items: Vec<Result<SubUpdate, RpcError>>,
446    ) -> BoxStream<'static, Result<SubUpdate, RpcError>> {
447        Box::pin(futures::stream::iter(items))
448    }
449
450    fn ok_update(payload: &str, skipped: u64) -> Result<SubUpdate, RpcError> {
451        Ok(SubUpdate::new(Payload::from(payload.as_bytes())).with_skipped(skipped))
452    }
453
454    /// Collect a decoded stream, unwrapping every item as a delivered update.
455    async fn decoded(items: Vec<Result<SubUpdate, RpcError>>) -> Vec<RecordUpdate> {
456        decode_updates(raw_stream(items))
457            .map(|r| r.expect("no terminal error in this stream"))
458            .collect()
459            .await
460    }
461
462    #[tokio::test]
463    async fn gap_counts_reach_the_caller() {
464        let out = decoded(vec![ok_update(r#"{"n":1}"#, 0), ok_update(r#"{"n":2}"#, 7)]).await;
465
466        assert_eq!(out.len(), 2);
467        assert_eq!(out[0].value, Some(json!({ "n": 1 })));
468        assert_eq!(out[0].skipped, 0);
469        assert!(!out[0].has_gap());
470        assert_eq!(out[1].value, Some(json!({ "n": 2 })));
471        assert_eq!(out[1].skipped, 7, "the engine's gap count is preserved");
472        assert!(out[1].has_gap());
473    }
474
475    /// The burst's final update surfaces `snapshot_end` together with the loss
476    /// the burst accumulated.
477    #[tokio::test]
478    async fn snapshot_end_and_its_loss_reach_the_caller() {
479        let out = decoded(vec![
480            ok_update(r#"{"n":1}"#, 0),
481            Ok(SubUpdate::new(Payload::from(&b"{\"n\":2}"[..]))
482                .with_skipped(41)
483                .with_snapshot_end()),
484            ok_update(r#"{"n":3}"#, 0), // a live event, past the burst
485        ])
486        .await;
487
488        assert_eq!(out.len(), 3);
489        assert!(!out[0].snapshot_end);
490        assert!(out[1].snapshot_end, "the burst's final update is flagged");
491        assert_eq!(out[1].skipped, 41, "carrying the burst's whole loss");
492        assert!(!out[2].snapshot_end, "a live event does not close a burst");
493    }
494
495    /// A burst-final frame that fails to decode still closes the burst: it
496    /// arrives as `value: None` carrying its own `snapshot_end`. Nothing
497    /// follows it here, which is why the marker cannot ride a later update.
498    #[tokio::test]
499    async fn an_undecodable_burst_final_frame_still_closes_the_burst() {
500        let out = decoded(vec![
501            ok_update(r#"{"n":1}"#, 0),
502            Ok(SubUpdate::new(Payload::from(&b"not json"[..]))
503                .with_skipped(3)
504                .with_snapshot_end()),
505        ])
506        .await;
507
508        assert_eq!(out.len(), 2, "an undecodable frame is still delivered");
509        assert_eq!(out[1].value, None, "its payload did not decode");
510        assert!(
511            out[1].snapshot_end,
512            "burst completion must not depend on a later update that may never arrive"
513        );
514        assert_eq!(out[1].skipped, 3, "and its gap count stays exact");
515    }
516
517    #[tokio::test]
518    async fn topics_ride_along_and_rejection_ends_the_stream() {
519        let raw = raw_stream(vec![
520            Ok(
521                SubUpdate::tagged(Arc::from("temp.vienna"), Payload::from(&b"1"[..]))
522                    .with_skipped(2),
523            ),
524            Err(RpcError::Denied),
525            ok_update("99", 0), // past the terminal item — never delivered
526        ]);
527        let out: Vec<_> = decode_updates(raw).collect().await;
528
529        assert_eq!(out.len(), 2, "the rejection is the stream's last item");
530        let first = out[0].as_ref().expect("delivered update");
531        assert_eq!(first.topic.as_deref(), Some("temp.vienna"));
532        assert_eq!(first.value, Some(json!(1)));
533        assert_eq!(first.skipped, 2);
534
535        // The refusal is an `Err` item rather than a silent EOF, so a caller can
536        // tell it from a disconnect and fail instead of reporting a clean stop.
537        let err = out[1]
538            .as_ref()
539            .expect_err("the rejection reaches the caller");
540        assert!(
541            matches!(err, ClientError::ServerError { code, .. } if code == "denied"),
542            "unexpected terminal error: {err}"
543        );
544    }
545}