Skip to main content

liminal_server/console/
records.rs

1//! The console's records, in the field names of the console's own reader.
2//!
3//! The reader is `apps/liminal-console/src/live/types.ts` (`TopologyDocument`
4//! and `TapBatch`), so a record the server publishes is a record
5//! `parseRecord` reads.
6//!
7//! The bytes are the server's canonical form: the record is turned into a JSON
8//! value, whose maps keep their keys sorted, and serialised from there. That is
9//! the same output the channel's schema normaliser gives, so `v` arrives in
10//! sorted position, not first, and a reader takes every field by key.
11//!
12//! Left out of the topology because nothing in the server holds them yet:
13//! archives, exports, gates, federation and proposed routes, a link's round
14//! trip (sent as `null`) and refusals, and a line's product and stops beyond
15//! what the roster knows. Nothing is sent as a made-up value in their place.
16
17use std::num::NonZeroU32;
18
19use serde::Serialize;
20
21use super::error::ConsoleError;
22use super::wall::WallBase;
23use crate::tap::{self, TapIdent, TapOutcome, TapSource};
24
25/// The record version the console reads.
26pub const RECORD_VERSION: u64 = 1;
27
28/// A device: this one, or a cluster peer.
29#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
30pub struct Device {
31    /// The device id (the cluster node name, or `local` without a cluster).
32    pub id: String,
33    /// The device's name as shown.
34    pub name: String,
35    /// Always a device: federation has no source.
36    pub kind: DeviceKind,
37    /// The liminal version running there: known for this device (from the
38    /// build), not for a peer, whose version nothing here holds.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub liminal: Option<String>,
41    /// When the server's incarnation started, in wall-clock milliseconds
42    /// since the epoch: known for this device, not for a peer. A reader takes
43    /// the uptime as its own now less this, so the figure never goes stale
44    /// between documents (Hermes 7 Oct 01:49, which replaced `uptime`).
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub started: Option<u64>,
47    /// Why this device's start is missing, in the server's words: the clock
48    /// fault met building the document (Buttercup's finding 3, Hermes 03:05).
49    /// Present only when `started` is absent on this device, so never both.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub started_fault: Option<String>,
52    /// Where the map draws the device's zone (`box` in types.ts), when this
53    /// server places it.
54    #[serde(rename = "box", skip_serializing_if = "Option::is_none")]
55    pub zone: Option<DeviceBox>,
56}
57
58/// A device's zone on the console's map, in the map's own units.
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
60pub struct DeviceBox {
61    /// Left edge.
62    pub x: i64,
63    /// Top edge.
64    pub y: i64,
65    /// Width.
66    pub w: i64,
67    /// Height.
68    pub h: i64,
69}
70
71impl DeviceBox {
72    /// This server's own zone: the map's fitted area inset by 40 on each
73    /// side, the zone the console draws for a lone device (`NetworkPage`'s
74    /// `FIT` of -180, 20, 1420, 600).
75    pub const THIS: Self = Self {
76        x: -140,
77        y: 60,
78        w: 1340,
79        h: 520,
80    };
81}
82
83/// What a device is.
84#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
85#[serde(rename_all = "lowercase")]
86pub enum DeviceKind {
87    /// A liminal server.
88    Device,
89}
90
91/// A station: a registered service.
92#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
93pub struct Station {
94    /// The station id (the responder's subject).
95    pub id: String,
96    /// Its name as shown.
97    pub name: String,
98    /// The device it runs on.
99    pub device: String,
100    /// What kind of station.
101    pub kind: StationKind,
102    /// Its state.
103    pub state: StationState,
104    /// SRV-027 R3: the subjects a registered worker serves (its
105    /// registration's task queue). Absent on a station that is not a worker.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub subjects: Option<Vec<String>>,
108    /// SRV-027 R3: what a registered worker accepts and returns, each
109    /// activity's schemas as the exact strings it registered; empty for a
110    /// worker that declared none (raw bytes in and out). Absent on a
111    /// station that is not a worker.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub activities: Option<Vec<StationActivity>>,
114}
115
116/// One activity a worker declared at registration (SRV-027 R3): its name and
117/// its input and output schemas, carried as the strings the registration
118/// held, never parsed or normalised here.
119#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
120pub struct StationActivity {
121    /// The activity's name.
122    pub name: String,
123    /// The input schema as registered.
124    pub input_schema_json: String,
125    /// The output schema as registered.
126    pub output_schema_json: String,
127}
128
129/// What a station is. Only services have a source until the services
130/// registry exists.
131#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
132#[serde(rename_all = "lowercase")]
133pub enum StationKind {
134    /// A registered conversation responder.
135    Service,
136    /// A registered worker.
137    Worker,
138    /// This server's persistent store, which a durable line's messages
139    /// reach before the fan-out (lane brief ruling 5).
140    Depot,
141    /// This server's exchange, the fan-out every line passes; its id is the
142    /// device's (lane brief ruling 2).
143    Exchange,
144}
145
146/// A station's state. A registered responder is live; nothing in the server
147/// is asleep, held or retired as a station yet.
148#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
149#[serde(rename_all = "lowercase")]
150pub enum StationState {
151    /// Registered and answering.
152    Live,
153}
154
155/// A line: a stream on the roster.
156#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
157pub struct Line {
158    /// The channel name.
159    pub id: String,
160    /// Always a stream: calls, archives and exports have no source.
161    pub kind: LineKind,
162    /// How long its messages are kept.
163    pub life: LineLife,
164    /// Whether it admits.
165    pub state: LineState,
166    /// The stations it passes, in order.
167    pub stops: Vec<String>,
168}
169
170/// What a line is.
171#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
172#[serde(rename_all = "lowercase")]
173pub enum LineKind {
174    /// A channel.
175    Stream,
176}
177
178/// How long a line keeps its messages, from the channel's mode.
179#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
180#[serde(rename_all = "lowercase")]
181pub enum LineLife {
182    /// Ephemeral: messages pass and are not stored.
183    Passing,
184    /// Durable: messages are stored.
185    Kept,
186}
187
188/// Whether a line admits.
189#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
190#[serde(rename_all = "lowercase")]
191pub enum LineState {
192    /// Admitting publishes and subscribes.
193    Live,
194    /// Quiesced: refusing new publishes and subscribes.
195    Quiesced,
196}
197
198/// A link to a cluster peer, present while the peer is.
199#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
200pub struct Link {
201    /// The link id, `<this device>~<peer>`.
202    pub id: String,
203    /// The peer's name as shown.
204    pub name: String,
205    /// How the two devices are joined.
206    pub kind: LinkKind,
207    /// The link's state.
208    pub state: LinkState,
209    /// Round trip in milliseconds. No source: always `null`, never `0`.
210    pub rtt_ms: Option<u64>,
211    /// The two device ids it joins, in sorted order, so the same link has
212    /// the same bytes whichever end publishes it.
213    pub ends: [String; 2],
214}
215
216impl Link {
217    /// The link from `local` to `peer`, up, with no round trip measured.
218    #[must_use]
219    pub fn to_peer(local: &str, peer: &str) -> Self {
220        let ends = if peer < local {
221            [peer.to_owned(), local.to_owned()]
222        } else {
223            [local.to_owned(), peer.to_owned()]
224        };
225        Self {
226            id: format!("{local}~{peer}"),
227            name: peer.to_owned(),
228            kind: LinkKind::SameNetwork,
229            state: LinkState::Up,
230            rtt_ms: None,
231            ends,
232        }
233    }
234}
235
236/// How two devices are joined.
237#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
238pub enum LinkKind {
239    /// Members of one cluster.
240    #[serde(rename = "same network")]
241    SameNetwork,
242}
243
244/// A link's state. A departed peer's link is absent, not down: membership
245/// keeps no record of why it left.
246#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
247#[serde(rename_all = "lowercase")]
248pub enum LinkState {
249    /// The peer is a member.
250    Up,
251}
252
253/// A live connection to this server.
254#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
255pub struct Connection {
256    /// The connection id, as a tap message's `from` names it.
257    pub id: String,
258    /// The transport it arrived on.
259    pub mount: Mount,
260    /// The participant its pass names, when it presented one.
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub participant: Option<String>,
263}
264
265/// The transport a connection arrived on.
266#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
267#[serde(rename_all = "lowercase")]
268pub enum Mount {
269    /// The TCP wire listener.
270    Tcp,
271    /// The WebSocket listener.
272    Websocket,
273    /// The in-process transport.
274    Loopback,
275}
276
277impl From<crate::server::mount::MountKind> for Mount {
278    fn from(mount: crate::server::mount::MountKind) -> Self {
279        match mount {
280            crate::server::mount::MountKind::Tcp => Self::Tcp,
281            crate::server::mount::MountKind::WebSocket => Self::Websocket,
282            crate::server::mount::MountKind::Loopback => Self::Loopback,
283        }
284    }
285}
286
287/// One `console.topology` record: the whole document as it stands.
288#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
289pub struct TopologyDocument {
290    v: u64,
291    #[serde(rename = "type")]
292    record_type: &'static str,
293    /// One more than the previous document's, from 1 at start.
294    pub rev: u64,
295    /// The id of the device this server is, one of `devices`' ids (lane
296    /// brief ruling 1, Hermes 7 Oct 03:51): named once here, never as a flag
297    /// on each device.
298    pub this_device: String,
299    /// This device, then each peer.
300    pub devices: Vec<Device>,
301    /// The registered services.
302    pub stations: Vec<Station>,
303    /// Every roster channel outside `console.`.
304    pub lines: Vec<Line>,
305    /// One per peer.
306    pub links: Vec<Link>,
307    /// One per live connection.
308    pub connections: Vec<Connection>,
309}
310
311impl TopologyDocument {
312    /// A document at `rev`, from the server that is device `this_device`.
313    #[must_use]
314    pub const fn new(
315        rev: u64,
316        this_device: String,
317        devices: Vec<Device>,
318        stations: Vec<Station>,
319        lines: Vec<Line>,
320        peer_links: Vec<Link>,
321        connections: Vec<Connection>,
322    ) -> Self {
323        Self {
324            v: RECORD_VERSION,
325            record_type: "topology",
326            rev,
327            this_device,
328            devices,
329            stations,
330            lines,
331            links: peer_links,
332            connections,
333        }
334    }
335}
336
337/// What became of a tapped message, in the console's words.
338///
339/// Only the words the server has an honest cause for: the console's call words (reply,
340/// died, deadline), deferred, dropped and redelivered have none here.
341#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
342#[serde(rename_all = "lowercase")]
343pub enum Outcome {
344    /// Taken by at least one subscriber.
345    Delivered,
346    /// Admitted, and no subscriber matched.
347    Nobody,
348    /// Admitted; subscribers matched and none took it.
349    Unaccepted,
350    /// Refused at admission, by the schema, or by the library.
351    Refused,
352    /// The store could not say whether the append landed.
353    Unresolved,
354}
355
356impl From<TapOutcome> for Outcome {
357    fn from(outcome: TapOutcome) -> Self {
358        match outcome {
359            TapOutcome::Delivered => Self::Delivered,
360            TapOutcome::NoSubscriber => Self::Nobody,
361            TapOutcome::NoneAccepted => Self::Unaccepted,
362            TapOutcome::Unresolved => Self::Unresolved,
363            TapOutcome::Refused => Self::Refused,
364        }
365    }
366}
367
368/// One point a message crossed.
369#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
370pub struct Hop {
371    /// The device id.
372    pub node: String,
373    /// Milliseconds after the message's `t`.
374    pub at_ms: u64,
375}
376
377/// The facts a tap carries about one message. Payloads never ride here.
378#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
379pub struct TapMessage {
380    /// `<device>:<message id>` when admitted, `<device>:x<observation>`
381    /// otherwise.
382    pub id: String,
383    /// The subject (channel name).
384    pub subject: String,
385    /// Payload size in bytes.
386    pub size: u64,
387    /// What became of it.
388    pub outcome: Outcome,
389    /// Wall time it was first seen, in milliseconds since the epoch.
390    pub t: u64,
391    /// The publishing connection's id, or `liminal.<device>` for the server.
392    pub from: String,
393    /// The station that published it: the registered worker identity of the
394    /// publishing connection. Absent when that connection is not a registered
395    /// worker, and for the server's own records.
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub from_station: Option<String>,
398    /// This device's id.
399    pub to: String,
400    /// The stations that received it: each registered worker whose
401    /// subscription's inbox on this device queued the message, once, in
402    /// delivery order. A subscriber whose inbox refused it is not here; nor is
403    /// one that is no registered worker (it is counted in the outcome, never
404    /// named), nor a receiver on another device. `[]` when the fan-out was
405    /// recorded and reached no registered worker here; absent when it was not
406    /// recorded (a tap went live while the publish ran, or the tap could not
407    /// lend it a buffer), so a reader shows its receivers as not known rather
408    /// than as none.
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub to_stations: Option<Vec<String>>,
411    /// Where it went, in order.
412    pub hops: Vec<Hop>,
413    /// The id of the message it answers (its causal parent), when it has one.
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub reply_to: Option<String>,
416    /// Held by a rule. Rules do not exist yet, so always false.
417    pub held: bool,
418    /// Where a rule diverted it. Rules do not exist yet, so always absent.
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub diverted: Option<String>,
421}
422
423/// One `console.tap.<target>` record: one batch, every message in it admitted
424/// at the one divisor `sampled`.
425#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
426pub struct TapBatch {
427    v: u64,
428    #[serde(rename = "type")]
429    record_type: &'static str,
430    /// The node or stream the tap is on.
431    pub target: String,
432    /// One more than this tap's previous batch, from 1.
433    pub seq: u64,
434    /// N in "sampled 1 in N" for every message in this batch.
435    pub sampled: NonZeroU32,
436    /// Messages evicted from the tap's ring since its previous batch.
437    pub dropped: u64,
438    /// The messages, oldest first.
439    pub messages: Vec<TapMessage>,
440}
441
442impl TapBatch {
443    /// A batch for `target`.
444    #[must_use]
445    pub const fn new(
446        target: String,
447        seq: u64,
448        sampled: NonZeroU32,
449        dropped: u64,
450        messages: Vec<TapMessage>,
451    ) -> Self {
452        Self {
453            v: RECORD_VERSION,
454            record_type: "tap.batch",
455            target,
456            seq,
457            sampled,
458            dropped,
459            messages,
460        }
461    }
462}
463
464/// The canonical bytes of a record: through a JSON value, so every key is in
465/// sorted order at every depth, as the schema normaliser writes them.
466///
467/// # Errors
468/// Returns [`ConsoleError::Encode`] naming the record when serialisation fails.
469pub fn canonical_bytes<Record: Serialize>(
470    record: &Record,
471    kind: &'static str,
472) -> Result<Vec<u8>, ConsoleError> {
473    serde_json::to_value(record)
474        .and_then(|value| serde_json::to_vec(&value))
475        .map_err(|source| ConsoleError::Encode {
476            record: kind,
477            source,
478        })
479}
480
481/// The `console.tap.<target>` record for one tap batch on `device`.
482///
483/// Every message is named, timed and attributed in the console's terms. A
484/// payload, which only a test grant can carry today, does not ride the
485/// record: the console's reader names no field for one.
486///
487/// # Errors
488/// Returns [`ConsoleError::Clock`] when a message's time passes u64
489/// milliseconds.
490pub fn tap_batch_record(
491    device: &str,
492    target: &str,
493    batch: &tap::TapBatch,
494    wall: &WallBase,
495) -> Result<TapBatch, ConsoleError> {
496    let messages = batch
497        .messages
498        .iter()
499        .map(|message| tap_message(device, message, wall))
500        .collect::<Result<Vec<_>, _>>()?;
501    Ok(TapBatch::new(
502        target.to_owned(),
503        batch.seq,
504        batch.sampled,
505        batch.dropped,
506        messages,
507    ))
508}
509
510fn tap_message(
511    device: &str,
512    message: &tap::TapMessage,
513    wall: &WallBase,
514) -> Result<TapMessage, ConsoleError> {
515    Ok(TapMessage {
516        id: match message.ident {
517            TapIdent::Message(id) => format!("{device}:{id}"),
518            TapIdent::Observation(observation) => format!("{device}:x{observation}"),
519        },
520        subject: message.subject.clone(),
521        size: message.size,
522        outcome: message.outcome.into(),
523        t: wall.unix_ms_at(message.seen_at)?,
524        from: match message.source {
525            TapSource::Connection(connection) => connection.to_string(),
526            TapSource::Server => message
527                .station
528                .as_deref()
529                .map_or_else(|| format!("liminal.{device}"), str::to_owned),
530        },
531        from_station: message.station.as_deref().map(str::to_owned),
532        to: device.to_owned(),
533        to_stations: message.to_stations.as_deref().map(|stations| {
534            stations
535                .iter()
536                .map(|reached| reached.station.to_string())
537                .collect()
538        }),
539        hops: message
540            .hops
541            .iter()
542            .map(|hop| Hop {
543                node: hop.node.to_string(),
544                at_ms: hop.at_ms,
545            })
546            .collect(),
547        reply_to: message.reply_to.clone(),
548        held: message.held,
549        diverted: None,
550    })
551}