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 **split by thread**, mirroring the C `dbCa` split
16//! between the record-processing thread and the `dbCaTask`
17//! (`dbCa.c:1093-1260`):
18//!
19//! * **Synchronous methods** are the ones record processing calls while
20//!   it holds the record's advisory write gate (C `dbScanLock`). They
21//!   answer from cached, monitor-fed state and MUST NOT perform I/O —
22//!   C `dbCaGetLink` copies out of `pca->pgetNative` and never touches
23//!   the wire (`dbCa.c:419-506`). Their signature is what enforces
24//!   that: a `fn` cannot await, so it cannot suspend the record thread.
25//! * **Async methods** ([`LinkSet::get_value`],
26//!   [`LinkSet::connect_link`], [`LinkSet::put_value`],
27//!   [`LinkSet::flush_puts`]) are the `dbCaTask` half. They run on the
28//!   database's link work owner, never on a record-processing thread,
29//!   and MAY block on the network.
30//!
31//! Before this split the "MUST NOT perform I/O" rule was a doc comment
32//! on an `async fn`, so nothing stopped a new lset from suspending
33//! record processing inside the gate. It is now a type-level property.
34//!
35//! # Adding a new lset
36//!
37//! ```ignore
38//! struct MyLset { /* ... */ }
39//! #[epics_base_rs::async_trait]
40//! impl LinkSet for MyLset {
41//!     fn is_connected(&self, name: &str) -> bool { /* cached */ }
42//!     fn get_cached_value(&self, name: &str) -> Option<EpicsValue> { /* cached */ }
43//!     async fn get_value(&self, name: &str) -> Option<EpicsValue> { /* may do I/O */ }
44//!     /* etc. */
45//! }
46//! db.register_link_set("pva", Arc::new(MyLset { ... })).await;
47//! ```
48
49use std::sync::Arc;
50
51use crate::types::EpicsValue;
52
53/// DBF field type a link's value maps to — the Rust counterpart of
54/// the C `DBF_*` codes pvxs `pvaGetDBFtype` returns.
55///
56/// Mirrors `pvxs/ioc/pvalink_lset.cpp:199` (`pvaGetDBFtype`), which
57/// maps the cached NT value's `TypeCode` to a `DBF_*` constant; an
58/// NT `enum_t` structure maps to `DBF_ENUM`.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum LinkDbfType {
61    Char,
62    UChar,
63    Short,
64    UShort,
65    Long,
66    ULong,
67    Int64,
68    UInt64,
69    Float,
70    Double,
71    String,
72    Enum,
73}
74
75/// How an external OUT-link write should be delivered to the lset.
76///
77/// Mirrors the C dbCore split between a plain link put and a
78/// put-notify-aware put: `dbPutLink` (synchronous, no completion
79/// callback) vs `dbPutLinkAsync` (issued from `dbNotify`, where the
80/// source record's processing is held until the downstream put
81/// completes). pvxs's pvalink lset realises the same split as
82/// `pvaPutValue` (plain, `wait=false`) vs `pvaPutValueAsync`
83/// (`wait=true`, which sets `record._options.block` so the PUT
84/// request carries the block option and the source record is parked
85/// in `after_put` until the server acknowledges completion) —
86/// `pvxs/ioc/pvalink_lset.cpp` `putValue` / `putValueAsync`.
87///
88/// The database selects the op from the write context: a write that
89/// originates inside a put-notify / blocking-put chain (the source
90/// record carries a completion wait-set) uses [`Async`]; a plain
91/// record-processing OUT write uses [`Plain`].
92///
93/// [`Async`]: LinkPutOp::Async
94/// [`Plain`]: LinkPutOp::Plain
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
96pub enum LinkPutOp {
97    /// Plain put — fire-and-forget from the lset's perspective. Maps to
98    /// pvxs `pvaPutValue` (`wait=false`) / C `dbPutLink`.
99    #[default]
100    Plain,
101    /// Completion-aware put — the originating record is part of a
102    /// put-notify / blocking-put chain. Maps to pvxs `pvaPutValueAsync`
103    /// (`wait=true`, `record._options.block`) / C `dbPutLinkAsync`.
104    Async,
105}
106
107/// Whether an external OUT link will accept a staged write right now —
108/// the answer to C `dbCaPutLinkCallback`'s first gate:
109///
110/// ```c
111/// if (!pca->isConnected || !pca->hasWriteAccess) {
112///     epicsMutexUnlock(pca->lock);
113///     return -1;
114/// }
115/// ```
116/// (`dbCa.c:529-532`).
117///
118/// Answered from cached state only: it runs on a record-processing thread
119/// inside the record's advisory write gate, which is exactly where C never
120/// touches the network.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum PutAdmission {
123    /// The lset tracks this link and its channel is up — stage the write.
124    /// C's fall-through to `addAction` (`dbCa.c:593`).
125    Connected,
126    /// The lset tracks this link and will not take the write — the channel
127    /// is down, or it is up but the server denies write access. One variant
128    /// for both because C has one outcome for both: `-1`, stage nothing
129    /// (`dbCa.c:529-532`). The caller raises the owning record's
130    /// LINK/INVALID through `dbPutLink`'s `setLinkAlarm`
131    /// (`dbLink.c:434-448`).
132    ///
133    /// Named for the answer rather than for one of its two causes: the
134    /// earlier name `Disconnected` was why an implementation of this trait
135    /// answered `is_connected()` alone and admitted every write-denied link.
136    Refused,
137    /// The lset has never opened this link, so it cannot answer. C cannot
138    /// reach this state: `dbCaAddLink` opens every CA link at record-init
139    /// time (`dbCa.c` `addAction(pca, CA_CONNECT)`), so by the first
140    /// `dbCaPutLink` the `caLink` always exists. Our lsets open lazily on
141    /// first use instead, so the write is staged and the lset's own
142    /// `put_value` performs the open — dropping it would mean an OUT link
143    /// that never opens and therefore never connects.
144    Unopened,
145}
146
147/// One external link's report state — the `caLink` fields `dbcar` prints
148/// per link (`dbCaTest.c:95-133`).
149///
150/// C reads them off one `caLink` struct because dbCa owns the channel, the
151/// staged out-value and the connection callback together. Here those belong
152/// to two owners — the lset owns the channel and the connection edge, the
153/// database's link-put queue owns the staged out-value — so this type
154/// carries only the lset's half and `dbcar` joins it with
155/// [`PvDatabase::external_link_puts_coalesced_for`], C's `nNoWrite`.
156///
157/// [`PvDatabase::external_link_puts_coalesced_for`]: super::PvDatabase::external_link_puts_coalesced_for
158#[derive(Clone, Debug, Default, PartialEq, Eq)]
159pub struct LinkDiagnostics {
160    /// C's `pca->chid && ca_field_type(pca->chid) != TYPENOTCONN`
161    /// (`dbCaTest.c:95-97`) — the channel is up. Not
162    /// [`LinkSet::is_connected`], which is the *readable-cache* gate
163    /// `dbCaGetLink` applies; a channel that has connected but has yet to
164    /// deliver a monitor event counts as connected to `dbcar` and as
165    /// unreadable to a record.
166    pub connected: bool,
167    /// C `ca_host_name(pca->chid)` — the server's name with its port.
168    pub host: String,
169    /// C `ca_read_access(pca->chid)`.
170    pub read_access: bool,
171    /// C `ca_write_access(pca->chid)`.
172    pub write_access: bool,
173    /// C `pca->nDisconnect` (`dbCa.c:822`) — connect→disconnect edges since
174    /// the link was opened.
175    pub n_disconnect: u64,
176    /// `pvlOptInpNative`: the link has served a native input transfer
177    /// (`dbCa.c:456`, set on the first `dbCaGetLink` that needs the native
178    /// monitor).
179    pub input_native: bool,
180    /// `pvlOptInpString`: the link has served a *string* input transfer,
181    /// which C reaches only for a `DBR_ENUM` channel read as `DBR_STRING`
182    /// (`dbCa.c:441`).
183    pub input_string: bool,
184    /// `pvlOptOutNative` (`dbCa.c:557`). Dead at the pin: the assignment is
185    /// inside a `/* Disabled by ANJ ... */` comment, so C never sets it and
186    /// `dbcar` always prints the column blank.
187    pub output_native: bool,
188    /// `pvlOptOutString` (`dbCa.c:541`), disabled by the same ANJ comment.
189    pub output_string: bool,
190}
191
192/// Remote display / control / valueAlarm metadata snapshot for a
193/// link, as exposed by pvxs's pvalink lset metadata getters.
194///
195/// Mirrors the `pvxs/ioc/pvalink_lset.cpp` metadata getter set installed
196/// at `pvxs/ioc/pvalink_lset.cpp:706-732`:
197/// `pvaGetDBFtype`, `pvaGetElements`, `pvaGetControlLimits`,
198/// `pvaGetGraphicLimits`, `pvaGetAlarmLimits`, `pvaGetPrecision`,
199/// `pvaGetUnits`.
200///
201/// Every field is optional: pvxs's getters read the cached NT
202/// structure with `Value::as`, which leaves the caller's buffer
203/// unchanged when the sub-field is absent. `None` here means the
204/// remote NT value carried no such metadata — the record support
205/// then keeps its local/default metadata, exactly as the C path does.
206/// The link-backed metadata resolved for **one snapshot build** — the single
207/// channel through which a link's target metadata can reach a served
208/// [`Snapshot`](crate::server::snapshot::Snapshot).
209///
210/// The invariant it makes true by construction is *a served snapshot's
211/// link-backed metadata was resolved during THIS build*. Three things enforce
212/// it, none of them a runtime check:
213///
214/// * the field is private and [`LinkBacking::resolved`] is `pub(crate)`, so a
215///   crate outside `epics-base-rs` cannot make one and must go through
216///   [`PvDatabase::channel_snapshot_for_field`](crate::server::database::PvDatabase::channel_snapshot_for_field);
217/// * it borrows the resolve's own output, so it cannot outlive the resolve and
218///   there is nowhere to *store* it — a stale value is unrepresentable;
219/// * `RecordInstance` keeps no link-metadata map, so the consumer has no second
220///   source to read.
221///
222/// C needs no equivalent. `dbLock.c:725-760` merges every DB_LINK-connected
223/// record into one lock set behind one recursive mutex, so `get_units` and its
224/// siblings call `dbGetUnits`/`dbGetPrecision`/... inline under the target's
225/// lock (`dbDbLink.c:240-261`). The port has one `RwLock` per record and no
226/// lock sets, so it must resolve with no record lock held and hand the answer
227/// in; this type is that hand-off.
228#[derive(Clone, Copy)]
229pub struct LinkBacking<'a>(Backing<'a>);
230
231/// The answers a poster's resolve can carry. `Resolved` and `Declined`
232/// used to be the same value — an empty `HashMap` — and that is what left a
233/// window open: a subscriber arriving between the resolve's read lock and the
234/// post's write lock was handed the record's own C seed where the link's
235/// metadata belongs, because nothing downstream could tell "the link answered
236/// nothing" from "nobody had asked, so we never looked".
237///
238/// `Empty` is `Resolved` with nothing in it, as its own value: the resolve
239/// that had no link to walk — every record with no link-backed metadata,
240/// every cycle — answers it without constructing a map to be empty in.
241#[derive(Clone, Copy)]
242enum Backing<'a> {
243    Unresolved,
244    Resolved(&'a std::collections::HashMap<String, LinkMetadata>),
245    Empty,
246    Declined,
247}
248
249impl<'a> LinkBacking<'a> {
250    /// Nothing was resolved for this build: the record backs no field's
251    /// metadata with a link, or the caller is a path that serves only
252    /// non-link-backed fields. Every lookup answers `None`, which serves each
253    /// rset slot's C seed — the same answer C's untouched buffer gives for a
254    /// CONSTANT link, an unresolvable target, or a link its
255    /// `DBLINK_FLAG_VISITED` guard refused.
256    pub const fn none() -> Self {
257        Self(Backing::Unresolved)
258    }
259
260    /// The resolve DECLINED to run: this record had no subscriber when the
261    /// cycle asked, so nothing would have read the answer. A post that finds
262    /// a subscriber anyway — one that arrived in the window between that read
263    /// lock and this write lock — must NOT serve a link-backed field from it;
264    /// [`RecordInstance::make_monitor_snapshot`](crate::server::record::RecordInstance::make_monitor_snapshot)
265    /// is where that refusal lives.
266    pub(crate) const fn declined() -> Self {
267        Self(Backing::Declined)
268    }
269
270    /// What the links this record's metadata is backed by resolved to, keyed
271    /// by link field (`INPA`, `INPB`, ...), resolved with no record lock held.
272    pub const fn resolved(resolved: &'a std::collections::HashMap<String, LinkMetadata>) -> Self {
273        Self(Backing::Resolved(resolved))
274    }
275
276    /// A resolve that ran and had no link to walk — see [`PostBacking::empty`].
277    pub(crate) const fn empty() -> Self {
278        Self(Backing::Empty)
279    }
280
281    /// The metadata behind one link field. The *predicate* — whether this
282    /// served field is link-backed at all — stays with
283    /// `Record::link_backed_metadata_field`, so this type answers only the
284    /// value and carries one meaning.
285    pub(crate) fn metadata(&self, link_field: &str) -> Option<&'a LinkMetadata> {
286        match self.0 {
287            Backing::Resolved(m) => m.get(link_field),
288            Backing::Empty | Backing::Unresolved | Backing::Declined => None,
289        }
290    }
291
292    /// True for [`Self::none`] — the caller resolved nothing. Distinct from a
293    /// resolve that came back empty, and the difference is what the monitor
294    /// poster's `debug_assert` reads: a link-backed field posted through an
295    /// unresolved backing is a poster that skipped its resolve, whereas the
296    /// same field posted through an empty resolve is a link that genuinely
297    /// answered nothing and correctly serves its C seed.
298    pub(crate) const fn is_unresolved(&self) -> bool {
299        matches!(self.0, Backing::Unresolved)
300    }
301
302    /// See [`Self::declined`].
303    pub(crate) const fn is_declined(&self) -> bool {
304        matches!(self.0, Backing::Declined)
305    }
306}
307
308/// What a poster's resolve produced —
309/// `PvDatabase::resolve_link_backed_metadata_for_posts`.
310///
311/// Owns the map so the borrowed [`LinkBacking`] can be taken from it at the
312/// post, and — the reason it exists rather than a bare `HashMap` — carries the
313/// gated door's DECLINE, which a `HashMap` has no room for. A caller cannot
314/// flatten the two: the only way to a `LinkBacking` from here is
315/// [`Self::as_link_backing`], which keeps whichever one it was.
316pub enum PostBacking {
317    /// The resolve ran and walked its links; this is what they answered.
318    Resolved(std::collections::HashMap<String, LinkMetadata>),
319    /// The resolve ran and had no link to walk: no link field was set, or the
320    /// record backs no metadata with one. An answer, not a decline.
321    Empty,
322    /// The resolve declined — see `LinkBacking::declined`.
323    Declined,
324}
325
326impl PostBacking {
327    /// The resolve ran; this is its answer, empty or not.
328    pub(crate) const fn resolved(map: std::collections::HashMap<String, LinkMetadata>) -> Self {
329        Self::Resolved(map)
330    }
331
332    /// See [`Self::Empty`].
333    pub(crate) const fn empty() -> Self {
334        Self::Empty
335    }
336
337    /// The resolve declined — see [`LinkBacking::declined`].
338    pub(crate) const fn declined() -> Self {
339        Self::Declined
340    }
341
342    /// The borrowed view every poster hands to the record.
343    pub fn as_link_backing(&self) -> LinkBacking<'_> {
344        match self {
345            Self::Resolved(map) => LinkBacking::resolved(map),
346            Self::Empty => LinkBacking::empty(),
347            Self::Declined => LinkBacking::declined(),
348        }
349    }
350}
351
352/// The first half of a poster's resolve — what
353/// [`PvDatabase::plan_link_backed_metadata_for_posts`](crate::server::database::PvDatabase::plan_link_backed_metadata_for_posts)
354/// learned under the record's own lock, for
355/// [`PvDatabase::resolve_link_backed_metadata_plan`](crate::server::database::PvDatabase::resolve_link_backed_metadata_plan)
356/// to walk with none held. The two answers a [`PostBacking`] can give without
357/// a walk are settled here; only `Links` locks another record.
358pub(crate) enum MetadataPlan {
359    /// See [`PostBacking::Empty`].
360    Empty,
361    /// See [`PostBacking::Declined`].
362    Declined,
363    /// The set links whose targets carry the metadata, by link field.
364    Links(Vec<(String, std::sync::Arc<crate::server::record::ParsedLink>)>),
365}
366
367#[derive(Clone, Debug, Default, PartialEq)]
368pub struct LinkMetadata {
369    /// DBF type the remote value maps to (`pvaGetDBFtype`). A connected
370    /// link always reports a type — an unmappable value shape falls back
371    /// to `Long`, the `default:` arm of pvxs `pvaGetDBFtype`
372    /// (`pvxs/ioc/pvalink_lset.cpp:199-236`). `None` therefore means "not
373    /// connected" (no cached value), never "connected but unmappable".
374    pub dbf_type: Option<LinkDbfType>,
375    /// Element count: array length, or `1` for a scalar / any connected
376    /// non-array shape (`pvaGetElements`, `pvxs/ioc/pvalink_lset.cpp:242-257`).
377    /// As with `dbf_type`, `None` means "not connected".
378    pub element_count: Option<i64>,
379    /// `display.limitLow` / `display.limitHigh` (`pvaGetGraphicLimits`).
380    pub graphic_limits: Option<(f64, f64)>,
381    /// `control.limitLow` / `control.limitHigh` (`pvaGetControlLimits`).
382    pub control_limits: Option<(f64, f64)>,
383    /// `valueAlarm.{lowAlarmLimit,lowWarningLimit,highWarningLimit,
384    /// highAlarmLimit}` as `(lolo, lo, hi, hihi)` (`pvaGetAlarmLimits`).
385    pub alarm_limits: Option<(f64, f64, f64, f64)>,
386    /// `display.precision` (`pvaGetPrecision`).
387    pub precision: Option<i16>,
388    /// `display.units` (`pvaGetUnits`).
389    pub units: Option<String>,
390    /// `display.description` — carried so a link snapshot is complete;
391    /// pvxs exposes it through the same `fld_meta` cache.
392    pub description: Option<String>,
393    /// ENUM state labels of the remote channel, when it has any. The label
394    /// table a `DBR_STRING`-requesting reader (stringin/lsi INP, printf
395    /// `%s`) renders a remote enum index through — C `dbCa` keeps a second
396    /// `DBR_STRING` monitor (`pgetString`) for that read; here the labels
397    /// ride the same attribute fetch as the limits (CA: `DBR_CTRL_ENUM`).
398    pub enum_choices: Option<Vec<String>>,
399}
400
401/// Ungated remote alarm snapshot for a link — the remote
402/// `(severity, status, message)` the upstream PV carried at the last
403/// successful value read, WITHOUT the maximize-severity
404/// (`MS`/`NMS`/`MSI`) gate that [`LinkSet::alarm_severity`] applies for
405/// owning-record propagation.
406///
407/// This is the DB-link inspection counterpart pvxs exposes through
408/// `dbGetAlarm` / `dbGetAlarmMsg` — `pvaGetAlarmMsg` returns the cached
409/// `snap_severity` / `snap_message` directly and never consults the
410/// link's `sevr` mode (`pvxs/ioc/pvalink_lset.cpp:542-569`; `pvaGetAlarm`
411/// `:571-575` is the thin wrapper that calls it with no message buffer).
412/// A default
413/// `NMS` link must still report its remote severity here even though it
414/// does not maximize the owning record's severity.
415#[derive(Clone, Debug, Default, PartialEq)]
416pub struct RemoteAlarm {
417    /// Remote alarm severity (`0 = NO_ALARM` … `3 = INVALID`), the raw
418    /// cached `alarm.severity` — never gated by the link's `sevr` mode.
419    pub severity: i32,
420    /// Remote alarm status code, derived from `severity` exactly as
421    /// pvxs `pvaGetAlarmMsg` does (`LINK_ALARM` when severity is
422    /// non-`NO_ALARM`, else `NO_ALARM` — `pvxs/ioc/pvalink_lset.cpp:554`). See
423    /// [`RemoteAlarm::from_severity_message`].
424    pub status: i32,
425    /// Remote `alarm.message`. Empty when the remote carried none or
426    /// the severity is `NO_ALARM` (pvxs clears `snap_message` unless
427    /// `snap_severity != 0` — `pvxs/ioc/pvalink_lset.cpp:418-422`).
428    pub message: String,
429}
430
431impl RemoteAlarm {
432    /// Build a snapshot whose `status` is derived from `severity`
433    /// exactly as pvxs `pvaGetAlarmMsg` (`pvxs/ioc/pvalink_lset.cpp:554`):
434    /// `LINK_ALARM` when the remote severity is non-`NO_ALARM`, else
435    /// `NO_ALARM`. status and severity cannot disagree by construction.
436    pub fn from_severity_message(severity: i32, message: String) -> Self {
437        let status = if severity != 0 {
438            crate::server::recgbl::alarm_status::LINK_ALARM as i32
439        } else {
440            crate::server::recgbl::alarm_status::NO_ALARM as i32
441        };
442        Self {
443            severity,
444            status,
445            message,
446        }
447    }
448}
449
450/// Pluggable backend for one URL scheme's link operations.
451///
452/// All methods take `&self` so the implementation must use interior
453/// mutability for any cached state. None / false is the
454/// "unavailable" sentinel — the database falls back to a generic
455/// LINK/INVALID alarm when an lset returns None.
456#[async_trait::async_trait]
457pub trait LinkSet: Send + Sync {
458    /// True iff a fresh value is available for `name` without
459    /// blocking. Used by the record processing loop to decide
460    /// whether to mark the record's STAT as LINK_ALARM.
461    ///
462    /// Synchronous: asked on the record-processing thread inside the
463    /// record's advisory write gate. MUST NOT perform I/O.
464    fn is_connected(&self, name: &str) -> bool;
465
466    /// True iff `name` has completed every post-connect init action
467    /// iocInit's external-link wait holds for — C `testInitReady`
468    /// (`dbCa.c:835-845` at `ef4829829`, epics-base #856 "dbCa: iocInit
469    /// wait for all conditions" — post-`R7.0.10` and in no tag, so this
470    /// is the one citation here that is not pin-relative): connected with
471    /// the first monitor event cached AND
472    /// the attribute (metadata) fetch complete. Distinct from
473    /// [`Self::is_connected`], which is C's lset `isConnected` and keeps
474    /// its readable-cache semantics.
475    ///
476    /// Synchronous and non-blocking like `is_connected`; polled only by
477    /// the iocInit wait. Default: `is_connected` — right for an lset
478    /// with no post-connect init actions.
479    fn init_ready(&self, name: &str) -> bool {
480        self.is_connected(name)
481    }
482
483    /// Read the current value of `name`. Returns None when the
484    /// upstream isn't yet connected or the lset has no cache for
485    /// this name.
486    ///
487    /// MAY perform I/O (open the channel, issue a one-shot GET). It is
488    /// therefore called only from the database's link work owner task —
489    /// the record-processing path uses [`Self::get_cached_value`].
490    async fn get_value(&self, name: &str) -> Option<EpicsValue>;
491
492    /// Read `name` from cached, monitor-fed state ONLY — the
493    /// record-processing read. C `dbCaGetLink` (`dbCa.c:419-506`) copies
494    /// out of `pca->pgetNative`, the buffer the CA monitor callback
495    /// (`eventCallback`, `dbCa.c:891-967`, the fill at `:941-944`)
496    /// keeps fresh on the `dbCaTask`; it never opens a channel and never
497    /// waits on the wire. Returns None
498    /// when the link has no cached value yet, which is C returning -1 for
499    /// `!pca->isConnected` (`dbCa.c:430-435`) — the reading record takes
500    /// LINK/INVALID for that cycle.
501    ///
502    /// MUST NOT perform I/O — which is why this is a `fn` and
503    /// [`Self::get_value`] is an `async fn`.
504    ///
505    /// Default: `None`, i.e. "this lset keeps no cache". That is C's
506    /// `!pca->isConnected` arm verbatim: the reading record takes
507    /// LINK/INVALID for the cycle and the database stages the link's
508    /// open on the link work owner ([`Self::connect_link`]), which is
509    /// what warms the cache for the next cycle. An lset that CAN answer
510    /// from memory MUST override, or its links never read.
511    fn get_cached_value(&self, name: &str) -> Option<EpicsValue> {
512        let _ = name;
513        None
514    }
515
516    /// Open (subscribe / connect) `name` so later
517    /// [`Self::get_cached_value`] reads have a cache to serve — C
518    /// `dbCaAddLink` (`dbCa.c:397-401`), which stages a `CA_CONNECT`
519    /// action whose `ca_create_channel` +
520    /// `ca_add_array_event` run on the `dbCaTask`, not on the caller.
521    ///
522    /// **Called from the database's link work owner task**, so it MAY
523    /// block on the network. Idempotent: the owner may call it again for
524    /// a link that is already open or still connecting.
525    ///
526    /// More precisely, it is called on the tokio runtime the database
527    /// captured at construction, so `tokio::net` is usable here — and that
528    /// is the *only* place it is usable. A database built with no runtime
529    /// entered anywhere captured none, and there is no second executor that
530    /// could stand in: the process-global background executor deliberately
531    /// carries no `tokio::net` reactor. Such a database therefore never
532    /// calls this method at all; it refuses the link instead
533    /// (`PvDatabase::external_put_gate`). Do not read the absence of a
534    /// runtime as a reason to open the channel synchronously on the
535    /// caller — there is no caller thread that may block that way.
536    ///
537    /// Default: drive the lset's own lazy open by reading through
538    /// [`Self::get_value`] and discarding the result — correct for every
539    /// existing lset, and it runs off the record-processing thread.
540    async fn connect_link(&self, name: &str) {
541        let _ = self.get_value(name).await;
542    }
543
544    /// Non-blocking admission gate for an OUT-link write, asked on the
545    /// record-processing thread *before* the write is staged onto the
546    /// database's link-put queue — C `dbCaPutLinkCallback`'s
547    /// `if (!pca->isConnected || !pca->hasWriteAccess) return -1;`
548    /// (`db/dbCa.c:529-532` (`dbCaPutLinkCallback`); epics-base R7.0.10).
549    ///
550    /// MUST NOT perform I/O. It is the one lset call left inside the
551    /// record's advisory write gate, and the whole point of the queue is
552    /// that nothing there touches the network.
553    ///
554    /// Default: derive from [`Self::is_connected`], which the trait already
555    /// documents as answerable "without blocking" — i.e. only C's FIRST
556    /// operand. An lset whose protocol also carries a write right MUST
557    /// override and test both, because the default cannot see the second
558    /// one; so MUST an lset whose OUT links live in a different cache than
559    /// its INP links (pvalink keys its registry on direction), or every OUT
560    /// write to a perfectly healthy channel is refused.
561    fn put_admission(&self, name: &str) -> PutAdmission {
562        if self.is_connected(name) {
563            PutAdmission::Connected
564        } else {
565            PutAdmission::Refused
566        }
567    }
568
569    /// Write `value` to `name` with the delivery semantics named by
570    /// `op` ([`LinkPutOp::Plain`] for a fire-and-forget put,
571    /// [`LinkPutOp::Async`] for a put that is part of a put-notify /
572    /// blocking-put chain). Returns Err with a human-readable reason
573    /// on failure (denied, type-mismatch, no-such-pv, etc.). Default
574    /// impl rejects all writes — read-only lsets keep the default.
575    ///
576    /// **Called from the database's link-put owner task, never from a
577    /// record-processing thread** — this is the `dbCaTask` half of the
578    /// split (`db/dbCa.c:1161-1183` (`dbCaTask`); epics-base R7.0.10), so it
579    /// may block on the network.
580    ///
581    /// As with [`Self::connect_link`], "may block on the network" means "on
582    /// the tokio runtime the database captured", which is where the owner
583    /// dispatches it. A database that captured no runtime never reaches this
584    /// method: the write is refused at `PvDatabase::external_put_gate` with
585    /// nothing staged, C's shape for a put it cannot deliver
586    /// (`db/dbCa.c:529-532` (`dbCaPutLinkCallback`); R7.0.10).
587    async fn put_value(&self, name: &str, value: EpicsValue, op: LinkPutOp) -> Result<(), String> {
588        let _ = (name, value, op);
589        Err("link set is read-only".into())
590    }
591
592    /// Fire `name`'s forward link (FLNK): trigger the remote target to
593    /// process, transferring no value.
594    ///
595    /// The lset counterpart of C `dbScanFwdLink` → `lset->scanForward`
596    /// (`dbLink.c:475`), realised by the pvalink lset as `pvaScanForward`
597    /// (`pvxs/ioc/pvalink_lset.cpp:672-688`). A forward link is never
598    /// deferred ("FWD_LINK is never deferred, and always results in a
599    /// Put") and carries no staged value: it forces the remote record to
600    /// process when the source record fires its FLNK.
601    ///
602    /// The lset applies the same non-retry validity gate pvxs does
603    /// (`pvxs/ioc/pvalink_lset.cpp:677`): on a non-retry link that is not currently
604    /// connected it performs NO trigger and returns `Err`, so the caller
605    /// raises LINK/INVALID on the owning record — pvxs calls
606    /// `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "Disconn")` there.
607    ///
608    /// Default impl: `Ok(())` no-op. A read-only or DB-local lset
609    /// forwards nothing through this hook — a DB FLNK target is processed
610    /// directly by the database's `scanOnce` path (the DB lset's
611    /// `scanForward`), not through an external link set.
612    fn scan_forward(&self, name: &str) -> Result<(), String> {
613        let _ = name;
614        Ok(())
615    }
616
617    /// Flush any OUT-link writes the lset has queued but not yet sent —
618    /// the production drain trigger for an async OUT channel owner.
619    ///
620    /// Two queued states this drains: a write deferred for sibling
621    /// coalescing, and a write that failed mid-disconnect and is held
622    /// for replay once the upstream reconnects (`retry`). The database
623    /// calls this after every external OUT-link write so the
624    /// "retry on connect" path has a production caller from record
625    /// processing — not only test code. Default no-op: a synchronous
626    /// lset (DB links, a read-only lset) queues nothing.
627    ///
628    /// Mirrors the role of pvxs's shared `pvaLinkChannel::put()` being
629    /// driven from record processing rather than left to manual calls
630    /// (`pvxs/ioc/pvalink_lset.cpp:653`, `pvxs/ioc/pvalink_channel.cpp:220-280`).
631    async fn flush_puts(&self) {}
632
633    /// Most recent alarm message string from the upstream PV, when
634    /// available. None means no alarm or no cache.
635    fn alarm_message(&self, _name: &str) -> Option<String> {
636        None
637    }
638
639    /// Alarm severity (`0 = NO_ALARM` … `3 = INVALID`) to fold into
640    /// the owning record's `LINK_ALARM`, when the link should
641    /// propagate one.
642    ///
643    /// `None` means "do not propagate" — either the upstream has no
644    /// alarm, the lset has no cache, or the link's maximize-severity
645    /// mode (`NMS`/`MS`/`MSI`) suppresses it. The lset is expected to
646    /// apply that mode gate itself (the `pva://X?sevr=MS` modifier is
647    /// stripped before epics-base-rs sees the link, so only the lset
648    /// retains it). A returned `Some(sev)` is therefore already
649    /// gated and the record processing loop propagates it verbatim
650    /// as a maximize-severity contribution. Mirrors pvxs
651    /// `pvxs/ioc/pvalink_lset.cpp` `pvaGetAlarm` feeding `recGblSetSevr`.
652    fn alarm_severity(&self, _name: &str) -> Option<i32> {
653        None
654    }
655
656    /// Remote alarm *status* code (the EPICS `alarm_status` enum:
657    /// `0 = NO_ALARM`, `1 = READ`, … `17 = COMM`, …) from the upstream
658    /// PV, when available.
659    ///
660    /// used to honour the `MSS` (maximize-severity-and-
661    /// status) link modifier — the owning record then adopts the remote
662    /// STAT instead of the generic `LINK_ALARM`. `None` means the lset
663    /// cannot report a remote status (no cache, or the link set does not
664    /// track it); the caller falls back to `LINK_ALARM`, which is the
665    /// behaviour for every non-`MSS` modifier and for lsets that leave
666    /// this default. Mirrors `pvxs/ioc/pvalink_lset.cpp` `pvaGetAlarm`
667    /// surfacing the remote `alarm.status` to `recGblSetSevrMsg`.
668    fn alarm_status(&self, _name: &str) -> Option<i32> {
669        None
670    }
671
672    /// Ungated remote alarm snapshot — the remote `(severity, status,
673    /// message)` after a successful value read, WITHOUT the
674    /// maximize-severity (`MS`/`NMS`/`MSI`) gate that
675    /// [`LinkSet::alarm_severity`] applies.
676    ///
677    /// This is the split pvxs draws between two operations: `pvaGetValue`
678    /// applies the `sevr` gate only when raising the *owning record's*
679    /// `LINK_ALARM` (`pvxs/ioc/pvalink_lset.cpp:424-431` — surfaced here
680    /// through [`LinkSet::alarm_severity`]), whereas `pvaGetAlarmMsg`
681    /// returns the cached `snap_severity` / `snap_message` snapshot
682    /// directly and never consults `sevr`
683    /// (`pvxs/ioc/pvalink_lset.cpp:542-569`, with `pvaGetAlarm` `:571-575`
684    /// its no-message-buffer wrapper — surfaced here). A caller
685    /// inspecting the DB link's alarm (`dbGetAlarm` / `dbGetAlarmMsg`)
686    /// therefore sees the remote severity even on a default `NMS` link
687    /// that leaves the owning record unraised.
688    ///
689    /// `None` means the lset cannot report a snapshot: no cache, the
690    /// link is not connected (pvxs `CHECK_VALID` — `pvxs/ioc/pvalink_lset.cpp:548`),
691    /// or the link set does not track remote alarms. Default: none.
692    fn remote_alarm(&self, _name: &str) -> Option<RemoteAlarm> {
693        None
694    }
695
696    /// `(seconds_past_epoch, nanoseconds, userTag)` from the upstream
697    /// PV's timestamp slot, when available. The `userTag` is the remote
698    /// `timeStamp.userTag` widened to the 64-bit `epicsUTag` tag without
699    /// sign extension, or `0` when the source carries none (CA links, or
700    /// a PVA source whose timeStamp omits the field).
701    fn time_stamp(&self, _name: &str) -> Option<(i64, i32, u64)> {
702        None
703    }
704
705    /// Remote display / control / valueAlarm metadata for `name`, as
706    /// a single snapshot.
707    ///
708    /// The Rust counterpart of pvxs's pvalink lset metadata getter
709    /// set (`pvaGetDBFtype`, `pvaGetElements`, `pvaGetControlLimits`,
710    /// `pvaGetGraphicLimits`, `pvaGetAlarmLimits`, `pvaGetPrecision`,
711    /// `pvaGetUnits` — installed at `pvxs/ioc/pvalink_lset.cpp:706-732`).
712    /// A structured snapshot is used instead of seven separate trait
713    /// methods so the lset reads its cache once and record support
714    /// gets every linked-metadata field together.
715    ///
716    /// `None` means the lset has no cached value for `name` (not yet
717    /// connected); a `Some(LinkMetadata)` with individual `None`
718    /// fields means the remote NT value simply did not carry that
719    /// piece of metadata — the record then keeps its local default,
720    /// matching the C getters that leave the caller's buffer
721    /// untouched on a missing sub-field. Default impl: no metadata.
722    fn link_metadata(&self, _name: &str) -> Option<LinkMetadata> {
723        None
724    }
725
726    /// Enumerate every PV name this lset has *opened* (i.e., is
727    /// actively tracking). Used by `dbpvxr` to dump per-record
728    /// link state without forcing the caller to know the full
729    /// name list up-front.
730    fn link_names(&self) -> Vec<String> {
731        Vec::new()
732    }
733
734    /// This link's `dbcar` report state, or `None` when the lset has never
735    /// opened `name` — C's `pca == NULL`, which `dbcar` prints as a
736    /// not-connected link with zero counters (`dbCaTest.c:127-132`).
737    ///
738    /// Async because C's host field is `ca_host_name(pca->chid)`, and this
739    /// port's twin (`CaChannel::host_name`) resolves the peer's PTR record
740    /// on a blocking thread exactly as libca's `hostNameCache` does. That
741    /// makes this the one `LinkSet` method neither half of the C split owns:
742    /// it is a diagnostic, called from iocsh, never from record processing
743    /// and never from the link work owner.
744    ///
745    /// Default: `None`, i.e. "this lset has no per-link report state".
746    /// Such an lset's links are invisible to `dbcar`, which is right — C's
747    /// `dbcar` walks `plink->type == CA_LINK` and no other link flavour.
748    async fn link_diagnostics(&self, _name: &str) -> Option<LinkDiagnostics> {
749        None
750    }
751}
752
753/// Type-erased lset reference held by the [`LinkSetRegistry`].
754pub type DynLinkSet = Arc<dyn LinkSet>;
755
756/// Per-scheme registry. Held in a snapshot cell inside
757/// [`super::PvDatabase`]: readers take an `Arc` of the whole registry with no
758/// lock, and `register` rebuilds and republishes under the cell's writer gate.
759/// `Clone` is what makes that rebuild possible; it is a per-scheme `Arc`
760/// clone, not a deep copy.
761#[derive(Clone, Default)]
762pub struct LinkSetRegistry {
763    inner: std::collections::HashMap<String, DynLinkSet>,
764}
765
766impl LinkSetRegistry {
767    pub fn new() -> Self {
768        Self {
769            inner: std::collections::HashMap::new(),
770        }
771    }
772
773    /// Register `lset` under `scheme`. Subsequent calls for the same
774    /// scheme replace the previous binding.
775    pub fn register(&mut self, scheme: &str, lset: DynLinkSet) {
776        self.inner.insert(scheme.to_string(), lset);
777    }
778
779    /// Look up the lset for `scheme`. Returns `None` when nothing is
780    /// registered under that scheme.
781    pub fn get(&self, scheme: &str) -> Option<DynLinkSet> {
782        self.inner.get(scheme).cloned()
783    }
784
785    /// Names of every registered scheme (`["pva", "ca", ...]`).
786    pub fn schemes(&self) -> Vec<String> {
787        self.inner.keys().cloned().collect()
788    }
789
790    /// Number of registered schemes.
791    pub fn len(&self) -> usize {
792        self.inner.len()
793    }
794
795    pub fn is_empty(&self) -> bool {
796        self.inner.is_empty()
797    }
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803
804    struct StubLset;
805    #[async_trait::async_trait]
806    impl LinkSet for StubLset {
807        fn is_connected(&self, _: &str) -> bool {
808            true
809        }
810        fn get_cached_value(&self, _: &str) -> Option<EpicsValue> {
811            Some(EpicsValue::Long(42))
812        }
813        async fn get_value(&self, name: &str) -> Option<EpicsValue> {
814            self.get_cached_value(name)
815        }
816    }
817
818    #[epics_macros_rs::epics_test]
819    async fn register_and_lookup() {
820        let mut reg = LinkSetRegistry::new();
821        assert!(reg.is_empty());
822        reg.register("pva", Arc::new(StubLset));
823        assert_eq!(reg.len(), 1);
824        let lset = reg.get("pva").expect("registered");
825        assert!(lset.is_connected("anything"));
826        assert_eq!(lset.get_value("anything").await, Some(EpicsValue::Long(42)));
827    }
828
829    #[test]
830    fn unknown_scheme_returns_none() {
831        let reg = LinkSetRegistry::new();
832        assert!(reg.get("missing").is_none());
833    }
834
835    /// The window the poster gate opens, and why `Declined` is not an empty
836    /// map. The gate is asked under a read lock the link walk then releases;
837    /// a subscriber can attach before the post takes the write lock. Driven
838    /// by hand here — the interleaving is not reproducible on demand, but a
839    /// declined backing is exactly the state it leaves behind, so the post's
840    /// behaviour on one is the whole of the fix.
841    ///
842    /// C closes this by lock discipline: `db_add_event` takes `dbScanLock`
843    /// on the record's lock set, which `dbProcess` holds across the cycle,
844    /// so a monitor attaching mid-cycle joins after it and first hears from
845    /// the NEXT process. Refusing reproduces that outcome.
846    #[epics_macros_rs::epics_test]
847    async fn a_late_subscriber_is_refused_rather_than_served_the_records_own_seed() {
848        use crate::server::recgbl::EventMask;
849        use crate::types::DbFieldType;
850
851        let db = crate::server::database::PvDatabase::new();
852        // PREC 1 / `mm` deliberately unlike the calc's own PREC 7 / `V`, so
853        // serving the record instead of the link would be visible.
854        let mut src = crate::server::records::ai::AiRecord::new(1.0);
855        src.egu = "mm".into();
856        src.prec = 1;
857        db.add_record("SRC", Box::new(src)).await.unwrap();
858        let mut calc = crate::server::records::calc::CalcRecord::default();
859        calc.egu = "V".into();
860        calc.prec = 7;
861        calc.set_inp_link(0, "SRC");
862        calc.calc = "A+1".into();
863        db.add_record("CALC", Box::new(calc)).await.unwrap();
864        db.ioc_init().await;
865
866        let rec = db.get_record("CALC").expect("record exists");
867
868        // Nobody subscribed: the gate declines. An empty map here would be
869        // indistinguishable from "walked the links and found nothing", which
870        // is what let the seed through.
871        let backing = db.resolve_link_backed_metadata_for_posts(&rec);
872        assert!(
873            backing.as_link_backing().is_declined(),
874            "with no subscribers the poster door declines rather than resolving"
875        );
876
877        // The subscribers arrive inside the window.
878        let full = (EventMask::VALUE | EventMask::LOG).bits();
879        let mut a_rx = rec
880            .write()
881            .add_subscriber("A", 1, DbFieldType::Double, full)
882            .expect("A subscriber");
883        let mut val_rx = rec
884            .write()
885            .add_subscriber("VAL", 2, DbFieldType::Double, full)
886            .expect("VAL subscriber");
887
888        // The post the cycle goes on to make, with the backing it is holding.
889        rec.write()
890            .notify_field_with_origin("A", EventMask::VALUE, 0, backing.as_link_backing());
891        rec.write()
892            .notify_field_with_origin("VAL", EventMask::VALUE, 0, backing.as_link_backing());
893
894        assert!(
895            a_rx.try_recv().is_err(),
896            "A is link-backed and nothing was resolved: the event is refused,              not sent carrying CALC's own PREC 7 where SRC's PREC 1 belongs"
897        );
898        assert!(
899            val_rx.try_recv().is_ok(),
900            "VAL is not link-backed: a declined backing costs it nothing"
901        );
902    }
903}