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