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(¶ms)?).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}