Skip to main content

epics_base_rs/server/database/
link_set.rs

1//! [`LinkSet`] — pluggable backend for `pva://` / `ca://` link
2//! resolution.
3//!
4//! Mirrors the C EPICS `lset` (link set) abstraction used by libdbCore
5//! to delegate link operations to a pluggable backend. We expose a
6//! pure-Rust trait so the bridge crate can wire up `pvalink` /
7//! `calink` without epics-base-rs having to know about either
8//! protocol.
9//!
10//! At runtime [`super::PvDatabase`] holds a registry keyed by URL
11//! scheme (`"pva"`, `"ca"`); each entry is an `Arc<dyn LinkSet>`.
12//! Record-link reads dispatch through the matching lset before
13//! falling back to the legacy `ExternalPvResolver` closure.
14//!
15//! The trait is **async**. An lset's state is async state: pvalink and
16//! calink both open links, and cache them, on a tokio runtime. A sync
17//! trait forced them to reach that state through `block_in_place` +
18//! `Handle::block_on`, which panics on a current-thread runtime —
19//! including the one `#[epics::test]` builds and the one every asyn port
20//! actor runs. Since every caller of these methods is already an
21//! `async fn` (the database's link paths), awaiting the answer costs
22//! nothing and removes the bridge.
23//!
24//! # Adding a new lset
25//!
26//! ```ignore
27//! struct MyLset { /* ... */ }
28//! #[epics_base_rs::async_trait]
29//! impl LinkSet for MyLset {
30//!     async fn is_connected(&self, name: &str) -> bool { /* ... */ }
31//!     async fn get_value(&self, name: &str) -> Option<EpicsValue> { /* ... */ }
32//!     /* etc. */
33//! }
34//! db.register_link_set("pva", Arc::new(MyLset { ... })).await;
35//! ```
36
37use std::sync::Arc;
38
39use crate::types::EpicsValue;
40
41/// DBF field type a link's value maps to — the Rust counterpart of
42/// the C `DBF_*` codes pvxs `pvaGetDBFtype` returns.
43///
44/// Mirrors `pvxs/ioc/pvalink_lset.cpp:199` (`pvaGetDBFtype`), which
45/// maps the cached NT value's `TypeCode` to a `DBF_*` constant; an
46/// NT `enum_t` structure maps to `DBF_ENUM`.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum LinkDbfType {
49    Char,
50    UChar,
51    Short,
52    UShort,
53    Long,
54    ULong,
55    Int64,
56    UInt64,
57    Float,
58    Double,
59    String,
60    Enum,
61}
62
63/// How an external OUT-link write should be delivered to the lset.
64///
65/// Mirrors the C dbCore split between a plain link put and a
66/// put-notify-aware put: `dbPutLink` (synchronous, no completion
67/// callback) vs `dbPutLinkAsync` (issued from `dbNotify`, where the
68/// source record's processing is held until the downstream put
69/// completes). pvxs's pvalink lset realises the same split as
70/// `pvaPutValue` (plain, `wait=false`) vs `pvaPutValueAsync`
71/// (`wait=true`, which sets `record._options.block` so the PUT
72/// request carries the block option and the source record is parked
73/// in `after_put` until the server acknowledges completion) —
74/// `pvxs/ioc/pvalink_lset.cpp` `putValue` / `putValueAsync`.
75///
76/// The database selects the op from the write context: a write that
77/// originates inside a put-notify / blocking-put chain (the source
78/// record carries a completion wait-set) uses [`Async`]; a plain
79/// record-processing OUT write uses [`Plain`].
80///
81/// [`Async`]: LinkPutOp::Async
82/// [`Plain`]: LinkPutOp::Plain
83#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
84pub enum LinkPutOp {
85    /// Plain put — fire-and-forget from the lset's perspective. Maps to
86    /// pvxs `pvaPutValue` (`wait=false`) / C `dbPutLink`.
87    #[default]
88    Plain,
89    /// Completion-aware put — the originating record is part of a
90    /// put-notify / blocking-put chain. Maps to pvxs `pvaPutValueAsync`
91    /// (`wait=true`, `record._options.block`) / C `dbPutLinkAsync`.
92    Async,
93}
94
95/// Remote display / control / valueAlarm metadata snapshot for a
96/// link, as exposed by pvxs's pvalink lset metadata getters.
97///
98/// Mirrors the pvxs `pvalink_lset.cpp` metadata getter set installed
99/// at `pvxs/ioc/pvalink_lset.cpp:700`:
100/// `pvaGetDBFtype`, `pvaGetElements`, `pvaGetControlLimits`,
101/// `pvaGetGraphicLimits`, `pvaGetAlarmLimits`, `pvaGetPrecision`,
102/// `pvaGetUnits`.
103///
104/// Every field is optional: pvxs's getters read the cached NT
105/// structure with `Value::as`, which leaves the caller's buffer
106/// unchanged when the sub-field is absent. `None` here means the
107/// remote NT value carried no such metadata — the record support
108/// then keeps its local/default metadata, exactly as the C path does.
109#[derive(Clone, Debug, Default, PartialEq)]
110pub struct LinkMetadata {
111    /// DBF type the remote value maps to (`pvaGetDBFtype`). A connected
112    /// link always reports a type — an unmappable value shape falls back
113    /// to `Long`, the `default:` arm of pvxs `pvaGetDBFtype`
114    /// (`pvalink_lset.cpp:199-236`). `None` therefore means "not
115    /// connected" (no cached value), never "connected but unmappable".
116    pub dbf_type: Option<LinkDbfType>,
117    /// Element count: array length, or `1` for a scalar / any connected
118    /// non-array shape (`pvaGetElements`, `pvalink_lset.cpp:242-254`).
119    /// As with `dbf_type`, `None` means "not connected".
120    pub element_count: Option<i64>,
121    /// `display.limitLow` / `display.limitHigh` (`pvaGetGraphicLimits`).
122    pub graphic_limits: Option<(f64, f64)>,
123    /// `control.limitLow` / `control.limitHigh` (`pvaGetControlLimits`).
124    pub control_limits: Option<(f64, f64)>,
125    /// `valueAlarm.{lowAlarmLimit,lowWarningLimit,highWarningLimit,
126    /// highAlarmLimit}` as `(lolo, lo, hi, hihi)` (`pvaGetAlarmLimits`).
127    pub alarm_limits: Option<(f64, f64, f64, f64)>,
128    /// `display.precision` (`pvaGetPrecision`).
129    pub precision: Option<i16>,
130    /// `display.units` (`pvaGetUnits`).
131    pub units: Option<String>,
132    /// `display.description` — carried so a link snapshot is complete;
133    /// pvxs exposes it through the same `fld_meta` cache.
134    pub description: Option<String>,
135}
136
137/// Ungated remote alarm snapshot for a link — the remote
138/// `(severity, status, message)` the upstream PV carried at the last
139/// successful value read, WITHOUT the maximize-severity
140/// (`MS`/`NMS`/`MSI`) gate that [`LinkSet::alarm_severity`] applies for
141/// owning-record propagation.
142///
143/// This is the DB-link inspection counterpart pvxs exposes through
144/// `dbGetAlarm` / `dbGetAlarmMsg` — `pvaGetAlarmMsg` returns the cached
145/// `snap_severity` / `snap_message` directly and never consults the
146/// link's `sevr` mode (`pvxs/ioc/pvalink_lset.cpp:542-575`). A default
147/// `NMS` link must still report its remote severity here even though it
148/// does not maximize the owning record's severity.
149#[derive(Clone, Debug, Default, PartialEq)]
150pub struct RemoteAlarm {
151    /// Remote alarm severity (`0 = NO_ALARM` … `3 = INVALID`), the raw
152    /// cached `alarm.severity` — never gated by the link's `sevr` mode.
153    pub severity: i32,
154    /// Remote alarm status code, derived from `severity` exactly as
155    /// pvxs `pvaGetAlarmMsg` does (`LINK_ALARM` when severity is
156    /// non-`NO_ALARM`, else `NO_ALARM` — `pvalink_lset.cpp:551`). See
157    /// [`RemoteAlarm::from_severity_message`].
158    pub status: i32,
159    /// Remote `alarm.message`. Empty when the remote carried none or
160    /// the severity is `NO_ALARM` (pvxs clears `snap_message` unless
161    /// `snap_severity != 0` — `pvalink_lset.cpp:418-422`).
162    pub message: String,
163}
164
165impl RemoteAlarm {
166    /// Build a snapshot whose `status` is derived from `severity`
167    /// exactly as pvxs `pvaGetAlarmMsg` (`pvalink_lset.cpp:551`):
168    /// `LINK_ALARM` when the remote severity is non-`NO_ALARM`, else
169    /// `NO_ALARM`. status and severity cannot disagree by construction.
170    pub fn from_severity_message(severity: i32, message: String) -> Self {
171        let status = if severity != 0 {
172            crate::server::recgbl::alarm_status::LINK_ALARM as i32
173        } else {
174            crate::server::recgbl::alarm_status::NO_ALARM as i32
175        };
176        Self {
177            severity,
178            status,
179            message,
180        }
181    }
182}
183
184/// Pluggable backend for one URL scheme's link operations.
185///
186/// All methods take `&self` so the implementation must use interior
187/// mutability for any cached state. None / false is the
188/// "unavailable" sentinel — the database falls back to a generic
189/// LINK/INVALID alarm when an lset returns None.
190#[async_trait::async_trait]
191pub trait LinkSet: Send + Sync {
192    /// True iff a fresh value is available for `name` without
193    /// blocking. Used by the record processing loop to decide
194    /// whether to mark the record's STAT as LINK_ALARM.
195    async fn is_connected(&self, name: &str) -> bool;
196
197    /// Read the current value of `name`. Returns None when the
198    /// upstream isn't yet connected or the lset has no cache for
199    /// this name.
200    async fn get_value(&self, name: &str) -> Option<EpicsValue>;
201
202    /// Write `value` to `name` with the delivery semantics named by
203    /// `op` ([`LinkPutOp::Plain`] for a fire-and-forget put,
204    /// [`LinkPutOp::Async`] for a put that is part of a put-notify /
205    /// blocking-put chain). Returns Err with a human-readable reason
206    /// on failure (denied, type-mismatch, no-such-pv, etc.). Default
207    /// impl rejects all writes — read-only lsets keep the default.
208    async fn put_value(&self, name: &str, value: EpicsValue, op: LinkPutOp) -> Result<(), String> {
209        let _ = (name, value, op);
210        Err("link set is read-only".into())
211    }
212
213    /// Fire `name`'s forward link (FLNK): trigger the remote target to
214    /// process, transferring no value.
215    ///
216    /// The lset counterpart of C `dbScanFwdLink` → `lset->scanForward`
217    /// (`dbLink.c:475`), realised by the pvalink lset as `pvaScanForward`
218    /// (`pvxs/ioc/pvalink_lset.cpp:672-688`). A forward link is never
219    /// deferred ("FWD_LINK is never deferred, and always results in a
220    /// Put") and carries no staged value: it forces the remote record to
221    /// process when the source record fires its FLNK.
222    ///
223    /// The lset applies the same non-retry validity gate pvxs does
224    /// (`pvalink_lset.cpp:677`): on a non-retry link that is not currently
225    /// connected it performs NO trigger and returns `Err`, so the caller
226    /// raises LINK/INVALID on the owning record — pvxs calls
227    /// `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "Disconn")` there.
228    ///
229    /// Default impl: `Ok(())` no-op. A read-only or DB-local lset
230    /// forwards nothing through this hook — a DB FLNK target is processed
231    /// directly by the database's `scanOnce` path (the DB lset's
232    /// `scanForward`), not through an external link set.
233    async fn scan_forward(&self, name: &str) -> Result<(), String> {
234        let _ = name;
235        Ok(())
236    }
237
238    /// Flush any OUT-link writes the lset has queued but not yet sent —
239    /// the production drain trigger for an async OUT channel owner.
240    ///
241    /// Two queued states this drains: a write deferred for sibling
242    /// coalescing, and a write that failed mid-disconnect and is held
243    /// for replay once the upstream reconnects (`retry`). The database
244    /// calls this after every external OUT-link write so the
245    /// "retry on connect" path has a production caller from record
246    /// processing — not only test code. Default no-op: a synchronous
247    /// lset (DB links, a read-only lset) queues nothing.
248    ///
249    /// Mirrors the role of pvxs's shared `pvaLinkChannel::put()` being
250    /// driven from record processing rather than left to manual calls
251    /// (`pvxs/ioc/pvalink_lset.cpp:647`, `pvalink_channel.cpp:220-263`).
252    async fn flush_puts(&self) {}
253
254    /// Most recent alarm message string from the upstream PV, when
255    /// available. None means no alarm or no cache.
256    async fn alarm_message(&self, _name: &str) -> Option<String> {
257        None
258    }
259
260    /// Alarm severity (`0 = NO_ALARM` … `3 = INVALID`) to fold into
261    /// the owning record's `LINK_ALARM`, when the link should
262    /// propagate one.
263    ///
264    /// `None` means "do not propagate" — either the upstream has no
265    /// alarm, the lset has no cache, or the link's maximize-severity
266    /// mode (`NMS`/`MS`/`MSI`) suppresses it. The lset is expected to
267    /// apply that mode gate itself (the `pva://X?sevr=MS` modifier is
268    /// stripped before epics-base-rs sees the link, so only the lset
269    /// retains it). A returned `Some(sev)` is therefore already
270    /// gated and the record processing loop propagates it verbatim
271    /// as a maximize-severity contribution. Mirrors pvxs
272    /// `pvalink_lset.cpp` `pvaGetAlarm` feeding `recGblSetSevr`.
273    async fn alarm_severity(&self, _name: &str) -> Option<i32> {
274        None
275    }
276
277    /// Remote alarm *status* code (the EPICS `alarm_status` enum:
278    /// `0 = NO_ALARM`, `1 = READ`, … `17 = COMM`, …) from the upstream
279    /// PV, when available.
280    ///
281    /// used to honour the `MSS` (maximize-severity-and-
282    /// status) link modifier — the owning record then adopts the remote
283    /// STAT instead of the generic `LINK_ALARM`. `None` means the lset
284    /// cannot report a remote status (no cache, or the link set does not
285    /// track it); the caller falls back to `LINK_ALARM`, which is the
286    /// behaviour for every non-`MSS` modifier and for lsets that leave
287    /// this default. Mirrors pvxs `pvalink_lset.cpp` `pvaGetAlarm`
288    /// surfacing the remote `alarm.status` to `recGblSetSevrMsg`.
289    async fn alarm_status(&self, _name: &str) -> Option<i32> {
290        None
291    }
292
293    /// Ungated remote alarm snapshot — the remote `(severity, status,
294    /// message)` after a successful value read, WITHOUT the
295    /// maximize-severity (`MS`/`NMS`/`MSI`) gate that
296    /// [`LinkSet::alarm_severity`] applies.
297    ///
298    /// This is the split pvxs draws between two operations: `pvaGetValue`
299    /// applies the `sevr` gate only when raising the *owning record's*
300    /// `LINK_ALARM` (`pvxs/ioc/pvalink_lset.cpp:424-431` — surfaced here
301    /// through [`LinkSet::alarm_severity`]), whereas `pvaGetAlarmMsg`
302    /// returns the cached `snap_severity` / `snap_message` snapshot
303    /// directly and never consults `sevr`
304    /// (`pvxs/ioc/pvalink_lset.cpp:542-575` — surfaced here). A caller
305    /// inspecting the DB link's alarm (`dbGetAlarm` / `dbGetAlarmMsg`)
306    /// therefore sees the remote severity even on a default `NMS` link
307    /// that leaves the owning record unraised.
308    ///
309    /// `None` means the lset cannot report a snapshot: no cache, the
310    /// link is not connected (pvxs `CHECK_VALID` — `pvalink_lset.cpp:545`),
311    /// or the link set does not track remote alarms. Default: none.
312    async fn remote_alarm(&self, _name: &str) -> Option<RemoteAlarm> {
313        None
314    }
315
316    /// `(seconds_past_epoch, nanoseconds, userTag)` from the upstream
317    /// PV's timestamp slot, when available. The `userTag` is the remote
318    /// `timeStamp.userTag` widened to the 64-bit `epicsUTag` tag without
319    /// sign extension, or `0` when the source carries none (CA links, or
320    /// a PVA source whose timeStamp omits the field).
321    async fn time_stamp(&self, _name: &str) -> Option<(i64, i32, u64)> {
322        None
323    }
324
325    /// Remote display / control / valueAlarm metadata for `name`, as
326    /// a single snapshot.
327    ///
328    /// The Rust counterpart of pvxs's pvalink lset metadata getter
329    /// set (`pvaGetDBFtype`, `pvaGetElements`, `pvaGetControlLimits`,
330    /// `pvaGetGraphicLimits`, `pvaGetAlarmLimits`, `pvaGetPrecision`,
331    /// `pvaGetUnits` — installed at `pvxs/ioc/pvalink_lset.cpp:700`).
332    /// A structured snapshot is used instead of seven separate trait
333    /// methods so the lset reads its cache once and record support
334    /// gets every linked-metadata field together.
335    ///
336    /// `None` means the lset has no cached value for `name` (not yet
337    /// connected); a `Some(LinkMetadata)` with individual `None`
338    /// fields means the remote NT value simply did not carry that
339    /// piece of metadata — the record then keeps its local default,
340    /// matching the C getters that leave the caller's buffer
341    /// untouched on a missing sub-field. Default impl: no metadata.
342    async fn link_metadata(&self, _name: &str) -> Option<LinkMetadata> {
343        None
344    }
345
346    /// Enumerate every PV name this lset has *opened* (i.e., is
347    /// actively tracking). Used by `dbpvxr` to dump per-record
348    /// link state without forcing the caller to know the full
349    /// name list up-front.
350    async fn link_names(&self) -> Vec<String> {
351        Vec::new()
352    }
353}
354
355/// Type-erased lset reference held by the [`LinkSetRegistry`].
356pub type DynLinkSet = Arc<dyn LinkSet>;
357
358/// Per-scheme registry. Wrapped in [`tokio::sync::RwLock`] inside
359/// [`super::PvDatabase`] so registration and read-paths are
360/// independently mutable.
361#[derive(Default)]
362pub struct LinkSetRegistry {
363    inner: std::collections::HashMap<String, DynLinkSet>,
364}
365
366impl LinkSetRegistry {
367    pub fn new() -> Self {
368        Self {
369            inner: std::collections::HashMap::new(),
370        }
371    }
372
373    /// Register `lset` under `scheme`. Subsequent calls for the same
374    /// scheme replace the previous binding.
375    pub fn register(&mut self, scheme: &str, lset: DynLinkSet) {
376        self.inner.insert(scheme.to_string(), lset);
377    }
378
379    /// Look up the lset for `scheme`. Returns `None` when nothing is
380    /// registered under that scheme.
381    pub fn get(&self, scheme: &str) -> Option<DynLinkSet> {
382        self.inner.get(scheme).cloned()
383    }
384
385    /// Names of every registered scheme (`["pva", "ca", ...]`).
386    pub fn schemes(&self) -> Vec<String> {
387        self.inner.keys().cloned().collect()
388    }
389
390    /// Number of registered schemes.
391    pub fn len(&self) -> usize {
392        self.inner.len()
393    }
394
395    pub fn is_empty(&self) -> bool {
396        self.inner.is_empty()
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    struct StubLset;
405    #[async_trait::async_trait]
406    impl LinkSet for StubLset {
407        async fn is_connected(&self, _: &str) -> bool {
408            true
409        }
410        async fn get_value(&self, _: &str) -> Option<EpicsValue> {
411            Some(EpicsValue::Long(42))
412        }
413    }
414
415    #[tokio::test]
416    async fn register_and_lookup() {
417        let mut reg = LinkSetRegistry::new();
418        assert!(reg.is_empty());
419        reg.register("pva", Arc::new(StubLset));
420        assert_eq!(reg.len(), 1);
421        let lset = reg.get("pva").expect("registered");
422        assert!(lset.is_connected("anything").await);
423        assert_eq!(lset.get_value("anything").await, Some(EpicsValue::Long(42)));
424    }
425
426    #[test]
427    fn unknown_scheme_returns_none() {
428        let reg = LinkSetRegistry::new();
429        assert!(reg.get("missing").is_none());
430    }
431}