Skip to main content

aimdb_core/remote/
metadata.rs

1//! Record metadata types for remote introspection (feature `remote`).
2//!
3//! [`RecordMetadata`] describes a registered record's runtime state — buffer
4//! configuration, producer/consumer counts, the `writable` flag, and
5//! (feature-gated) buffer metrics / stage profiling — for the AimX `record.list`
6//! response. Computed on demand from a record's static structure; core keeps no
7//! per-record metadata state.
8
9use alloc::format;
10use alloc::string::{String, ToString};
11#[cfg(feature = "observability")]
12use alloc::vec::Vec;
13use core::any::TypeId;
14use serde::{Deserialize, Serialize};
15
16use crate::graph::RecordOrigin;
17use crate::record_id::{RecordId, RecordKey};
18
19/// Metadata about a registered record type
20///
21/// Provides information for remote introspection, including buffer
22/// configuration and producer/consumer counts.
23///
24/// When the `observability` feature is enabled, additional fields are included
25/// for buffer-level statistics (produced_count, consumed_count, etc.).
26///
27/// # Liveness is an observability extra
28///
29/// A `record.list` reply enumerates records the server **registered**, not
30/// records that have carried a value — a server registering a fixed pool up
31/// front lists the whole pool. Only `produced_count` distinguishes them, and
32/// it is `observability`-gated by design (liveness
33/// derives from buffer counters, which constrained targets compile away). So a
34/// server whose clients need that distinction must be built with
35/// `observability` on; without it the honest client answer is "unknown".
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct RecordMetadata {
38    /// Unique record identifier (index in the storage)
39    pub record_id: u32,
40
41    /// Unique record key (stable identifier for lookup)
42    pub record_key: String,
43
44    /// Record type name (Rust type name)
45    pub name: String,
46
47    /// TypeId as hexadecimal string
48    pub type_id: String,
49
50    /// How the record gets its values (Source, Link, Transform, Passive)
51    pub origin: RecordOrigin,
52
53    /// Buffer type: "spmc_ring", "single_latest", "mailbox", or "none"
54    pub buffer_type: String,
55
56    /// Buffer capacity (None for unbounded or no buffer)
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub buffer_capacity: Option<usize>,
59
60    /// Number of registered producer services
61    pub producer_count: usize,
62
63    /// Number of registered consumer services
64    pub consumer_count: usize,
65
66    /// Whether write operations are permitted for this record
67    pub writable: bool,
68
69    /// Number of outbound connector links registered
70    pub outbound_connector_count: usize,
71
72    /// Data-contract schema name (e.g. `"temperature"`), when the serving
73    /// dispatch owns a schema registry that can resolve it (the WebSocket
74    /// connector's `StreamableRegistry`). Core alone cannot map a `TypeId` to a
75    /// contract name and leaves it `None`.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub schema_type: Option<String>,
78
79    /// The record key's **leaf segment** — everything after the last `.` or `/`
80    /// (`"vienna"` for `"temp.vienna"` or `"sensors/temp/vienna"`).
81    ///
82    /// Only an entity identifier where the leaf *is* the node. Under a deeper
83    /// scheme it is not: `"station.17.temperature"` yields `"temperature"`, not
84    /// the `"17"` a caller wants — such clients must parse the key themselves.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub entity: Option<String>,
87
88    // ===== Buffer metrics (feature-gated) =====
89    /// Total items pushed to the buffer (metrics feature only).
90    ///
91    /// Doubles as the liveness signal: `> 0` has carried a value, `0` is an
92    /// untouched registration, absent is unknowable (see the type-level note).
93    #[cfg(feature = "observability")]
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub produced_count: Option<u64>,
96
97    /// Total items consumed from the buffer (metrics feature only)
98    #[cfg(feature = "observability")]
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub consumed_count: Option<u64>,
101
102    /// Total items dropped due to overflow/lag (metrics feature only)
103    #[cfg(feature = "observability")]
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub dropped_count: Option<u64>,
106
107    /// Current buffer occupancy: (items, capacity) (metrics feature only)
108    #[cfg(feature = "observability")]
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub occupancy: Option<(usize, usize)>,
111
112    // ===== Stage profiling (feature-gated) =====
113    /// Per-stage timing metrics (`.source()`/`.tap()`/`.link()`), if the
114    /// `observability` feature is enabled and any stage has been registered.
115    #[cfg(feature = "observability")]
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub stage_profiling: Option<Vec<crate::profiling::StageProfilingInfo>>,
118
119    // ===== Signal gauges (feature-gated) =====
120    /// Per-record domain-signal statistics (last/min/max/mean) fed by
121    /// `Observable::observe()`, if the `observability` feature is enabled and any
122    /// gauge has been registered.
123    #[cfg(feature = "observability")]
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub signal_stats: Option<Vec<crate::profiling::SignalStatsInfo>>,
126}
127
128impl RecordMetadata {
129    /// Creates a new record metadata entry
130    ///
131    /// # Arguments
132    /// * `record_id` - The RecordId index
133    /// * `record_key` - The unique record key
134    /// * `type_id` - The TypeId of the record
135    /// * `name` - The Rust type name
136    /// * `origin` - How the record gets its values (Source, Link, Transform, Passive)
137    /// * `buffer_type` - Buffer type string
138    /// * `buffer_capacity` - Optional buffer capacity
139    /// * `producer_count` - Number of producers
140    /// * `consumer_count` - Number of consumers
141    /// * `writable` - Whether writes are permitted
142    /// * `outbound_connector_count` - Number of outbound connectors
143    #[allow(clippy::too_many_arguments)]
144    pub fn new<K: RecordKey>(
145        record_id: RecordId,
146        record_key: K,
147        type_id: TypeId,
148        name: String,
149        origin: RecordOrigin,
150        buffer_type: String,
151        buffer_capacity: Option<usize>,
152        producer_count: usize,
153        consumer_count: usize,
154        writable: bool,
155        outbound_connector_count: usize,
156    ) -> Self {
157        let entity = Some(super::topic_leaf(record_key.as_str()).to_string());
158        Self {
159            record_id: record_id.raw(),
160            record_key: record_key.as_str().to_string(),
161            name,
162            type_id: format!("{:?}", type_id),
163            origin,
164            buffer_type,
165            buffer_capacity,
166            producer_count,
167            consumer_count,
168            writable,
169            outbound_connector_count,
170            schema_type: None,
171            entity,
172            #[cfg(feature = "observability")]
173            produced_count: None,
174            #[cfg(feature = "observability")]
175            consumed_count: None,
176            #[cfg(feature = "observability")]
177            dropped_count: None,
178            #[cfg(feature = "observability")]
179            occupancy: None,
180            #[cfg(feature = "observability")]
181            stage_profiling: None,
182            #[cfg(feature = "observability")]
183            signal_stats: None,
184        }
185    }
186
187    /// Sets buffer metrics from a snapshot (metrics feature only)
188    ///
189    /// Populates produced_count, consumed_count, dropped_count, and occupancy
190    /// from the provided metrics snapshot.
191    #[cfg(feature = "observability")]
192    pub fn with_buffer_metrics(mut self, snapshot: crate::buffer::BufferMetricsSnapshot) -> Self {
193        self.produced_count = Some(snapshot.produced_count);
194        self.consumed_count = Some(snapshot.consumed_count);
195        self.dropped_count = Some(snapshot.dropped_count);
196        // Only include occupancy if it's meaningful (non-zero capacity)
197        if snapshot.occupancy.1 > 0 {
198            self.occupancy = Some(snapshot.occupancy);
199        }
200        self
201    }
202
203    /// Attaches a stage profiling snapshot (profiling feature only).
204    #[cfg(feature = "observability")]
205    pub fn with_stage_profiling(
206        mut self,
207        stages: Vec<crate::profiling::StageProfilingInfo>,
208    ) -> Self {
209        if !stages.is_empty() {
210            self.stage_profiling = Some(stages);
211        }
212        self
213    }
214
215    /// Attaches a signal-gauge snapshot (observability feature only).
216    #[cfg(feature = "observability")]
217    pub fn with_signal_stats(mut self, signals: Vec<crate::profiling::SignalStatsInfo>) -> Self {
218        if !signals.is_empty() {
219            self.signal_stats = Some(signals);
220        }
221        self
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::record_id::StringKey;
229
230    #[test]
231    fn test_record_metadata_creation() {
232        let type_id = TypeId::of::<i32>();
233        let metadata = RecordMetadata::new(
234            RecordId::new(0),
235            StringKey::new("test.record"),
236            type_id,
237            "i32".to_string(),
238            RecordOrigin::Source,
239            "spmc_ring".to_string(),
240            Some(100),
241            1,
242            2,
243            false,
244            0,
245        );
246
247        assert_eq!(metadata.record_id, 0);
248        assert_eq!(metadata.record_key, "test.record");
249        assert_eq!(metadata.name, "i32");
250        assert!(matches!(metadata.origin, RecordOrigin::Source));
251        assert_eq!(metadata.buffer_type, "spmc_ring");
252        assert_eq!(metadata.buffer_capacity, Some(100));
253        assert_eq!(metadata.producer_count, 1);
254        assert_eq!(metadata.consumer_count, 2);
255        assert_eq!(metadata.outbound_connector_count, 0);
256        assert!(!metadata.writable);
257    }
258
259    #[test]
260    fn test_record_metadata_serialization() {
261        let type_id = TypeId::of::<String>();
262        let metadata = RecordMetadata::new(
263            RecordId::new(1),
264            StringKey::new("app.config"),
265            type_id,
266            "String".to_string(),
267            RecordOrigin::Passive,
268            "single_latest".to_string(),
269            None,
270            1,
271            1,
272            true,
273            2,
274        );
275
276        let json = serde_json::to_string(&metadata).unwrap();
277        assert!(json.contains("\"record_id\":1"));
278        assert!(json.contains("\"record_key\":\"app.config\""));
279        assert!(json.contains("\"name\":\"String\""));
280        assert!(json.contains("\"origin\":\"passive\""));
281        assert!(json.contains("\"buffer_type\":\"single_latest\""));
282        assert!(json.contains("\"writable\":true"));
283        assert!(json.contains("\"outbound_connector_count\":2"));
284    }
285}