Skip to main content

epics_base_rs/server/database/
field_io.rs

1use std::collections::HashSet;
2
3use crate::error::{CaError, CaResult};
4use crate::server::snapshot::Snapshot;
5use crate::types::EpicsValue;
6
7use super::PvDatabase;
8
9impl PvDatabase {
10    /// Get a PV value synchronously from a blocking thread.
11    ///
12    /// Uses `block_in_place` + `Handle::block_on` to bridge the async
13    /// `get_pv` call. Safe to call from std::threads spawned within
14    /// a tokio runtime context.
15    pub fn get_pv_blocking(&self, name: &str) -> CaResult<EpicsValue> {
16        let db = self.clone();
17        let name = name.to_string();
18        if crate::runtime::task::RuntimeHandle::try_current().is_ok() {
19            crate::__tokio::task::block_in_place(|| {
20                crate::runtime::task::RuntimeHandle::current().block_on(db.get_pv(&name))
21            })
22        } else {
23            Err(CaError::InvalidValue(
24                "no runtime for get_pv_blocking".into(),
25            ))
26        }
27    }
28
29    /// Get the current value of a PV or record field.
30    /// Uses resolve_field for records (3-level priority).
31    pub async fn get_pv(&self, name: &str) -> CaResult<EpicsValue> {
32        let (base, field) = super::parse_pv_name(name);
33        let field = field.to_ascii_uppercase();
34
35        // Check simple PVs first (exact match)
36        if let Some(pv) = self.inner.simple_pvs.read().await.get(name) {
37            return Ok(pv.get().await);
38        }
39
40        // Records — alias-aware via `get_record` (epics-base PR #336).
41        if let Some(rec) = self.get_record(base).await {
42            let instance = rec.read().await;
43            return instance
44                .resolve_field(&field)
45                .ok_or_else(|| CaError::ChannelNotFound(name.to_string()));
46        }
47
48        Err(CaError::ChannelNotFound(name.to_string()))
49    }
50
51    /// Set a PV value or record field, notifying subscribers.
52    /// Tries record put_field first, then put_common_field as fallback.
53    ///
54    /// Acquires the record's advisory write gate.
55    pub async fn put_pv(&self, name: &str, value: EpicsValue) -> CaResult<()> {
56        self.put_pv_inner(name, value, true).await
57    }
58
59    /// `put_pv` variant for a caller already holding the
60    /// record's advisory write gate (QSRV atomic group PUT). See
61    /// [`Self::put_record_field_from_ca_already_locked`].
62    pub async fn put_pv_already_locked(&self, name: &str, value: EpicsValue) -> CaResult<()> {
63        self.put_pv_inner(name, value, false).await
64    }
65
66    async fn put_pv_inner(
67        &self,
68        name: &str,
69        value: EpicsValue,
70        acquire_gate: bool,
71    ) -> CaResult<()> {
72        let (base, field) = super::parse_pv_name(name);
73        let field = field.to_ascii_uppercase();
74
75        // Check simple PVs first
76        if let Some(pv) = self.inner.simple_pvs.read().await.get(name) {
77            pv.set(value).await;
78            return Ok(());
79        }
80
81        // Records — alias-aware (epics-base PR #336).
82        if let Some(rec) = self.get_record(base).await {
83            // `base` may be an alias; resolve to the canonical record
84            // name so scan-index updates target the right entry.
85            let canonical_base: String = self
86                .resolve_alias(base)
87                .await
88                .unwrap_or_else(|| base.to_string());
89            // advisory write gate (`dbScanLock` analogue) so a
90            // plain `put_pv` to a backing record cannot interleave
91            // with an atomic group transaction holding the same gate.
92            // Skipped when the caller already owns the gate.
93            let _record_gate = if acquire_gate {
94                Some(self.lock_record(&canonical_base).await)
95            } else {
96                None
97            };
98            let mut instance = rec.write().await;
99
100            // Coerce value to field's native type
101            let value = {
102                let target_type = instance
103                    .record
104                    .field_list()
105                    .iter()
106                    .find(|f| f.name.eq_ignore_ascii_case(&field))
107                    .map(|f| f.dbf_type);
108                if let Some(target) = target_type {
109                    if value.db_field_type() != target {
110                        // C EPICS dbPut (12cfd41): nRequest=0 into a scalar
111                        // field must NOT silently coerce. `convert_to` on an
112                        // empty array calls `to_f64().unwrap_or(0.0)` and
113                        // would produce a scalar zero — the same garbage-
114                        // value bug the C fix raised LINK_ALARM for.
115                        if value.is_empty_array() {
116                            return Err(CaError::InvalidValue(format!(
117                                "empty array cannot be coerced to scalar field {field}"
118                            )));
119                        }
120                        value.convert_to(target)
121                    } else {
122                        value
123                    }
124                } else {
125                    value
126                }
127            };
128
129            // Capture the pre-put value so the metadata-cache
130            // invalidation (and the downstream `DBE_PROPERTY`
131            // emission) can be skipped when the put is a no-op —
132            // epics-base faac1df1.
133            let prev_value = instance.record.get_field(&field);
134
135            // put_pv is C EPICS dbPut: write value + special/on_put.
136            // Does NOT post monitor events (use put_pv_and_post for that).
137            // Does NOT clear UDF or trigger processing.
138            use crate::server::record::CommonFieldPutResult;
139            let common_result = match instance.record.put_field(&field, value.clone()) {
140                Ok(()) => {
141                    instance.record.on_put(&field);
142                    let _ = instance.record.special(&field, true);
143                    CommonFieldPutResult::NoChange
144                }
145                Err(CaError::FieldNotFound(_)) => instance.put_common_field(&field, value)?,
146                Err(e) => return Err(e),
147            };
148
149            // Invalidate metadata cache only if the metadata-class
150            // field's value actually changed (faac1df1).
151            instance.notify_field_written_if_changed(&field, prev_value.as_ref());
152
153            // Update scan index if SCAN or PHAS changed
154            match common_result {
155                CommonFieldPutResult::ScanChanged {
156                    old_scan,
157                    new_scan,
158                    phas,
159                } => {
160                    drop(instance);
161                    self.update_scan_index(&canonical_base, old_scan, new_scan, phas, phas)
162                        .await;
163                }
164                CommonFieldPutResult::PhasChanged {
165                    scan: s,
166                    old_phas,
167                    new_phas,
168                } => {
169                    drop(instance);
170                    self.update_scan_index(&canonical_base, s, s, old_phas, new_phas)
171                        .await;
172                }
173                CommonFieldPutResult::NoChange => {}
174            }
175
176            // mirror the CA-write path's ASG-field notifier so
177            // restore scripts / autosave / admin tools that go via
178            // `put_pv` (not `put_record_field_from_ca`) also trigger
179            // per-client `reeval_access_rights`. C `dbAccess.c::
180            // dbPutSpecial` invokes the SPC_AS callback from dbPut
181            // regardless of caller entry path.
182            if field == "ASG" {
183                crate::server::access_security::notify_asg_field_changed();
184            }
185
186            return Ok(());
187        }
188
189        Err(CaError::ChannelNotFound(name.to_string()))
190    }
191
192    /// Write a value and post monitor events if changed.
193    /// Equivalent to C EPICS `dbPut` + `db_post_events(DBE_VALUE|DBE_LOG)`.
194    ///
195    /// Use for readback/status mirror PVs that are written by sequencer-style
196    /// code and need to be visible to CA monitors without triggering record
197    /// processing. Clears UDF/UDF_ALARM on primary field write.
198    ///
199    /// `origin`: writer ID for self-write filtering. Subscribers with the
200    /// same `ignore_origin` will skip this event. Pass 0 to disable.
201    pub async fn put_pv_and_post(&self, name: &str, value: EpicsValue) -> CaResult<()> {
202        self.put_pv_and_post_with_origin(name, value, 0).await
203    }
204
205    /// Push a monitor event holding the simple PV's *current* value
206    /// but with explicit alarm severity/status. Used by the gateway
207    /// to surface upstream-disconnect to downstream monitor
208    /// subscribers without dropping the shadow PV (which would force
209    /// downstream clients into ECA_DISCONN reconnect storms on every
210    /// transient hiccup). Returns `ChannelNotFound` for record-backed
211    /// PVs — those carry their own `common.sevr/stat` in record
212    /// processing.
213    pub async fn post_alarm(&self, name: &str, severity: u16, status: u16) -> CaResult<()> {
214        if let Some(pv) = self.inner.simple_pvs.read().await.get(name).cloned() {
215            pv.post_alarm(severity, status).await;
216            return Ok(());
217        }
218        Err(crate::error::CaError::ChannelNotFound(name.to_string()))
219    }
220
221    /// Propagate a full upstream snapshot (value + alarm status/severity +
222    /// IOC timestamp) to a simple shadow PV and fan out to downstream
223    /// monitor subscribers. Used by the CA gateway forwarding task to avoid
224    /// discarding the upstream alarm and timestamp decoded from the incoming
225    /// `DBR_TIME_*` frame. Returns `ChannelNotFound` for record-backed PVs
226    /// (those carry their own alarm engine and are not shadow PVs).
227    pub async fn put_pv_and_post_snapshot(&self, name: &str, snapshot: Snapshot) -> CaResult<()> {
228        if let Some(pv) = self.inner.simple_pvs.read().await.get(name).cloned() {
229            pv.set_snapshot(snapshot).await;
230            return Ok(());
231        }
232        Err(CaError::ChannelNotFound(name.to_string()))
233    }
234
235    /// Install upstream `DBR_CTRL_*` metadata (display / control limits,
236    /// enum labels) on a shadow simple PV WITHOUT posting an event.
237    ///
238    /// The CA gateway calls this once on upstream connect, after its initial
239    /// `DBR_CTRL_*` get, so a later downstream `DBR_CTRL_*` / `DBR_GR_*` read
240    /// returns the real limits instead of zeroed ones. No `DBE_PROPERTY`
241    /// monitor event fires — nothing has *changed* yet, this only seeds the
242    /// attribute cache. Mirrors C `gatePvData::getCB` → `runDataCB` →
243    /// `vc->setPvData(dd)` (`gatePv.cc:1693-1695`), which seeds the property
244    /// cache from the initial control get in both cache modes before any
245    /// monitor is enabled.
246    ///
247    /// Returns `ChannelNotFound` for record-backed PVs — those own their own
248    /// metadata via record processing and are not gateway shadow PVs.
249    pub async fn set_pv_metadata(&self, name: &str, snapshot: &Snapshot) -> CaResult<()> {
250        if let Some(pv) = self.inner.simple_pvs.read().await.get(name).cloned() {
251            pv.set_metadata(metadata_from_snapshot(snapshot));
252            return Ok(());
253        }
254        Err(CaError::ChannelNotFound(name.to_string()))
255    }
256
257    /// Refresh a shadow simple PV's upstream metadata AND post a
258    /// `DBE_PROPERTY` monitor event carrying `snapshot` to downstream
259    /// property subscribers.
260    ///
261    /// `snapshot` is the decoded upstream `DBR_CTRL_*` property event: it
262    /// carries the control value and the upstream `status` / `severity`,
263    /// and (because control DBR structs carry no timestamp) an undefined
264    /// timestamp the caller must NOT replace with a fresh wall-clock. The
265    /// gateway's property monitor calls this on every upstream
266    /// `DBE_PROPERTY` event, mirroring C `gatePvData::propEventCB` →
267    /// `runDataCB` + `setPvData` + `runValueDataCB` +
268    /// `vcPostEvent(propertyEventMask())` (`gatePv.cc:1571-1607`): the
269    /// attribute cache is refreshed and a property event is posted with the
270    /// upstream alarm state preserved (`setStatSevr`) and the undefined
271    /// control-DBR timestamp left as-is (`gatePv.cc:1594-1595`).
272    ///
273    /// Returns `ChannelNotFound` for record-backed PVs.
274    pub async fn post_pv_property(&self, name: &str, snapshot: Snapshot) -> CaResult<()> {
275        if let Some(pv) = self.inner.simple_pvs.read().await.get(name).cloned() {
276            pv.set_metadata(metadata_from_snapshot(&snapshot));
277            pv.post_property(snapshot).await;
278            return Ok(());
279        }
280        Err(CaError::ChannelNotFound(name.to_string()))
281    }
282
283    /// Like `put_pv_and_post` but with explicit origin tag.
284    pub async fn put_pv_and_post_with_origin(
285        &self,
286        name: &str,
287        value: EpicsValue,
288        origin: u64,
289    ) -> CaResult<()> {
290        let (base, field) = super::parse_pv_name(name);
291        let field = field.to_ascii_uppercase();
292
293        // Simple-PV path: PVs registered via `add_pv` (e.g. CA gateway
294        // shadow PVs, IOCsh stats PVs) are stored in `simple_pvs`,
295        // not `records`. Without this branch the function would
296        // silently return `ChannelNotFound` for every gateway-mirrored
297        // PV — `ProcessVariable::set` already does the
298        // notify-subscribers fan-out internally so all we need here is
299        // to delegate. The `origin` tag is a no-op for simple PVs
300        // because they don't yet plumb origin through `set`.
301        if let Some(pv) = self.inner.simple_pvs.read().await.get(name).cloned() {
302            let _ = origin; // simple PVs don't currently honor origin tagging
303            pv.set(value).await;
304            return Ok(());
305        }
306
307        if let Some(rec) = self.get_record(base).await {
308            // `put_pv_and_post` is a public record-write API —
309            // it must take the same advisory write gate
310            // (`dbScanLock` analogue) as `put_pv` /
311            // `put_record_field_from_ca`, or a gateway/sequencer
312            // write through this helper can still land between the
313            // member writes of a QSRV atomic group or a pvalink
314            // atomic scan epoch holding `lock_records`. `base` is
315            // alias-resolved to the canonical record name so an alias
316            // and its target share one gate. Held until return.
317            let canonical_base: String = self
318                .resolve_alias(base)
319                .await
320                .unwrap_or_else(|| base.to_string());
321            let _record_gate = self.lock_record(&canonical_base).await;
322
323            let mut instance = rec.write().await;
324
325            // Type coercion
326            let value = {
327                let target_type = instance
328                    .record
329                    .field_list()
330                    .iter()
331                    .find(|f| f.name.eq_ignore_ascii_case(&field))
332                    .map(|f| f.dbf_type);
333                if let Some(target) = target_type {
334                    if value.db_field_type() != target {
335                        // C EPICS dbPut (12cfd41): empty-array → scalar
336                        // coercion would produce silent zero; reject.
337                        if value.is_empty_array() {
338                            return Err(CaError::InvalidValue(format!(
339                                "empty array cannot be coerced to scalar field {field}"
340                            )));
341                        }
342                        value.convert_to(target)
343                    } else {
344                        value
345                    }
346                } else {
347                    value
348                }
349            };
350
351            let old_value = instance.record.get_field(&field);
352            let old_stat = instance.common.stat;
353            let old_sevr = instance.common.sevr;
354            // Snapshot side-effect-prone fields BEFORE the put. The
355            // array-family records (waveform/aai/aao/subArray) update
356            // NORD as a side-effect of put_field("VAL"); other record
357            // types return None for "NORD" and the comparison reduces
358            // to None==None → unchanged.
359            let old_nord = if field == "VAL" {
360                instance.record.get_field("NORD")
361            } else {
362                None
363            };
364
365            // Write value + special/on_put
366            match instance.record.put_field(&field, value.clone()) {
367                Ok(()) => {
368                    instance.record.on_put(&field);
369                    let _ = instance.record.special(&field, true);
370                    // Clear UDF/UDF_ALARM on primary field write
371                    if field == instance.record.primary_field() {
372                        instance.common.udf = false;
373                        if instance.common.stat == crate::server::recgbl::alarm_status::UDF_ALARM {
374                            instance.common.stat = 0;
375                            instance.common.sevr = crate::server::record::AlarmSeverity::NoAlarm;
376                        }
377                    }
378                }
379                Err(CaError::FieldNotFound(_)) => {
380                    instance.put_common_field(&field, value)?;
381                }
382                Err(e) => return Err(e),
383            }
384
385            // Invalidate metadata cache only if a metadata-class
386            // field actually changed value (faac1df1 — DBE_PROPERTY
387            // fires on real changes, not no-op writes).
388            instance.notify_field_written_if_changed(&field, old_value.as_ref());
389
390            // Post monitor events if value or alarm changed
391            let new_value = instance.record.get_field(&field);
392            let value_changed = old_value != new_value;
393            let alarm_changed =
394                old_stat != instance.common.stat || old_sevr != instance.common.sevr;
395            let new_nord = if field == "VAL" {
396                instance.record.get_field("NORD")
397            } else {
398                None
399            };
400            let nord_changed = field == "VAL" && old_nord != new_nord && new_nord.is_some();
401            if value_changed || alarm_changed || nord_changed {
402                // Update timestamp so the snapshot carries current time
403                instance.common.time = crate::runtime::general_time::get_current();
404                instance.cleanup_subscribers();
405                if value_changed || alarm_changed {
406                    instance.notify_field_with_origin(
407                        &field,
408                        crate::server::recgbl::EventMask::VALUE
409                            | crate::server::recgbl::EventMask::LOG
410                            | crate::server::recgbl::EventMask::ALARM,
411                        origin,
412                    );
413                }
414                // Surface the implicit NORD update to NORD subscribers
415                // for waveform/aai/aao/subArray. Without this, a CA
416                // gateway forwarding upstream waveform monitors via
417                // put_pv_and_post would update VAL on the shadow PV
418                // but leave downstream NORD subscribers stuck at their
419                // last seen length — a frozen-element-count bug that
420                // surfaces in PyDM image views and similar consumers
421                // that compute height = element_count / width.
422                if nord_changed {
423                    instance.notify_field_with_origin(
424                        "NORD",
425                        crate::server::recgbl::EventMask::VALUE
426                            | crate::server::recgbl::EventMask::LOG,
427                        origin,
428                    );
429                }
430            }
431
432            // same SPC_AS parity as `put_pv` / `put_pv_no_process`
433            // / the CA-write path — a gateway mirroring `.ASG` via
434            // `put_pv_and_post` must still trigger per-client
435            // re-eval.
436            if field == "ASG" {
437                crate::server::access_security::notify_asg_field_changed();
438            }
439
440            return Ok(());
441        }
442
443        Err(CaError::ChannelNotFound(name.to_string()))
444    }
445
446    /// CA client's unified entry point for record field put.
447    /// Handles DISP/PROC/PACT/LCNT checks, field put, device write, and Passive process.
448    ///
449    /// Acquires the record's advisory write gate
450    /// (`dbScanLock` analogue) for the duration of the write.
451    pub async fn put_record_field_from_ca(
452        &self,
453        record_name: &str,
454        field: &str,
455        value: EpicsValue,
456    ) -> CaResult<Option<crate::runtime::sync::oneshot::Receiver<()>>> {
457        self.put_record_field_from_ca_inner(record_name, field, value, true)
458            .await
459    }
460
461    /// Variant for a caller that already owns the target
462    /// record's advisory write gate — the QSRV atomic group PUT,
463    /// which acquired every member-record gate up-front via
464    /// [`Self::lock_records`]. The per-record `tokio::sync::Mutex`
465    /// gate is NOT reentrant, so the atomic group path MUST use this
466    /// `_already_locked` entry to avoid dead-locking on its own
467    /// `ManyRecordWriteGuard`.
468    pub async fn put_record_field_from_ca_already_locked(
469        &self,
470        record_name: &str,
471        field: &str,
472        value: EpicsValue,
473    ) -> CaResult<Option<crate::runtime::sync::oneshot::Receiver<()>>> {
474        self.put_record_field_from_ca_inner(record_name, field, value, false)
475            .await
476    }
477
478    async fn put_record_field_from_ca_inner(
479        &self,
480        record_name: &str,
481        field: &str,
482        value: EpicsValue,
483        acquire_gate: bool,
484    ) -> CaResult<Option<crate::runtime::sync::oneshot::Receiver<()>>> {
485        let field = field.to_ascii_uppercase();
486
487        // Get record Arc — alias-aware (epics-base PR #336) so a CA
488        // client that connects via an alias name can put fields on
489        // the canonical record.
490        let rec = self
491            .get_record(record_name)
492            .await
493            .ok_or_else(|| CaError::ChannelNotFound(record_name.to_string()))?;
494        // Normalise to the canonical name for the rest of this
495        // function — every subsequent call (PACT/LCNT lookup,
496        // `process_record_with_links`, `update_scan_index`) uses the
497        // raw records map and would miss when `record_name` is an
498        // alias. Resolve once up front.
499        let canonical_owned;
500        let record_name: &str = if let Some(target) = self.resolve_alias(record_name).await {
501            canonical_owned = target;
502            &canonical_owned
503        } else {
504            record_name
505        };
506
507        // take the record's advisory write gate — the
508        // `dbScanLock(precord)` analogue. While a QSRV atomic group
509        // PUT/GET holds this record's gate via `lock_records`, this
510        // plain write blocks here, so a direct backing-record write
511        // can no longer land between member writes of an atomic group
512        // transaction. Held until the function returns. Skipped when
513        // the caller (atomic group PUT) already owns the gate — the
514        // gate `Mutex` is not reentrant.
515        let _record_gate = if acquire_gate {
516            Some(self.lock_record(record_name).await)
517        } else {
518            None
519        };
520
521        // Special field intercepts (read lock, then drop)
522        {
523            let instance = rec.read().await;
524            match field.as_str() {
525                "PACT" => return Err(CaError::ReadOnlyField("PACT".into())),
526                "LCNT" => return Err(CaError::ReadOnlyField("LCNT".into())),
527                "PUTF" => return Err(CaError::ReadOnlyField("PUTF".into())),
528                _ => {}
529            }
530
531            // PROC intercept: trigger processing regardless of DISP.
532            // Falls through to the put_notify_tx registration below
533            // so async records (motor, asyn-backed AO) signal real
534            // completion; otherwise WRITE_NOTIFY would return ECA_NORMAL
535            // before the device move actually finished.
536            if field == "PROC" {
537                // C `dbPutField` (dbAccess.c:1265) matches the proc field by
538                // pointer with NO value check: any write to PROC — including
539                // 0 — processes the record (when !pact). The standard
540                // `caput REC.PROC 0` / `dbpf REC.PROC 0` force-process idiom
541                // must therefore not be skipped for a zero value.
542                drop(instance);
543                // Continue to the put-notify setup + process below
544                // by jumping past the field-write step (the value
545                // itself isn't stored; PROC is a trigger).
546                let (completion_tx, completion_rx) = crate::runtime::sync::oneshot::channel();
547                let notify = crate::server::record::NotifyWaitSet::new(completion_tx);
548                {
549                    let rec = self.inner.records.read().await;
550                    if let Some(rec_arc) = rec.get(record_name) {
551                        let mut guard = rec_arc.write().await;
552                        if guard.notify.is_some() {
553                            return Err(CaError::PutCallbackInProgress(record_name.to_string()));
554                        }
555                        guard.notify = Some(notify.clone());
556                    }
557                }
558                let mut visited = HashSet::new();
559                // this PROC trigger already holds `record_name`'s
560                // advisory write gate — either `_record_gate` above, or
561                // the QSRV atomic group's `lock_records` epoch when
562                // entered via `put_record_field_from_ca_already_locked`.
563                // The gate `Mutex` is not reentrant, so the processing
564                // call MUST use the `_already_locked` variant.
565                let _ = self
566                    .process_record_with_links_already_locked(record_name, &mut visited, 0)
567                    .await;
568                // The wait-set fires the oneshot only after the whole
569                // FLNK/OUT chain (sync + async) settles. If it has
570                // already completed the chain was fully synchronous —
571                // report immediate success; otherwise hand the receiver
572                // to the CA layer to await the deferred completion.
573                return if notify.completed() {
574                    Ok(None)
575                } else {
576                    Ok(Some(completion_rx))
577                };
578            }
579
580            // DISP check: block CA puts to non-DISP fields when DISP=1
581            if instance.common.disp && field != "DISP" {
582                return Err(CaError::PutDisabled(field));
583            }
584        }
585
586        // Normal field put (write lock)
587        let common_result = {
588            let mut instance = rec.write().await;
589            instance.common.putf = true;
590
591            // Coerce value to the field's native DBR type (e.g. String → Double for ao.VAL).
592            // This matches C EPICS db_put_field() which converts from the CA client's type
593            // to the record field's native type.
594            let value = {
595                let target_type = instance
596                    .record
597                    .field_list()
598                    .iter()
599                    .find(|f| f.name.eq_ignore_ascii_case(&field))
600                    .map(|f| f.dbf_type);
601                if let Some(target) = target_type {
602                    if value.db_field_type() != target {
603                        // C EPICS dbPut (12cfd41): empty-array → scalar
604                        // coercion would produce silent zero; reject.
605                        if value.is_empty_array() {
606                            instance.common.putf = false;
607                            return Err(CaError::InvalidValue(format!(
608                                "empty array cannot be coerced to scalar field {field}"
609                            )));
610                        }
611                        value.convert_to(target)
612                    } else {
613                        value
614                    }
615                } else {
616                    value
617                }
618            };
619
620            // SPC_NOMOD: reject writes to read-only fields (C EPICS S_db_noMod)
621            let is_read_only = instance
622                .record
623                .field_list()
624                .iter()
625                .find(|f| f.name.eq_ignore_ascii_case(&field))
626                .is_some_and(|f| f.read_only);
627            if is_read_only {
628                instance.common.putf = false;
629                return Err(CaError::ReadOnlyField(field));
630            }
631
632            // Pre-write special hook (C EPICS dbPutSpecial pass=0)
633            if let Err(e) = instance.record.special(&field, false) {
634                instance.common.putf = false;
635                return Err(e);
636            }
637
638            // Capture pre-put value for faac1df1 idempotent-write suppression.
639            let prev_value = instance.record.get_field(&field);
640
641            // Try record-specific field first; fall back to common on FieldNotFound.
642            // For record-owned fields, call on_put() and special() after successful put,
643            // matching what put_common_field() does for common fields.
644            use crate::server::record::CommonFieldPutResult;
645            let common_result = match instance.record.put_field(&field, value.clone()) {
646                Ok(()) => {
647                    instance.record.on_put(&field);
648                    let _ = instance.record.special(&field, true);
649                    // C `dbAccess.c::dbPut:1410-1411` clears
650                    // `precord->udf = FALSE` synchronously when the
651                    // put target is the record-type's primary value
652                    // field (`dbIsValueField`). The clear happens
653                    // BEFORE `dbProcess` runs, so any reader between
654                    // the put and the process-cycle's own clear sees
655                    // the new value with a consistent UDF=false.
656                    //
657                    // Rust's processing path also clears UDF via
658                    // `clears_udf()` in process/complete_async_record,
659                    // but that runs AFTER the put lock drops and the
660                    // process re-acquires — leaving a small window
661                    // where another reader can observe (new VAL,
662                    // udf=true). For async records the window spans
663                    // the entire device round trip. Clear here to
664                    // close the window. The same clear already exists
665                    // in `put_pv_and_post` (line 256-262); mirror it.
666                    if field == instance.record.primary_field() {
667                        instance.common.udf = false;
668                        if instance.common.stat == crate::server::recgbl::alarm_status::UDF_ALARM {
669                            instance.common.stat = 0;
670                            instance.common.sevr = crate::server::record::AlarmSeverity::NoAlarm;
671                        }
672                    }
673                    CommonFieldPutResult::NoChange
674                }
675                Err(CaError::FieldNotFound(_)) => instance.put_common_field(&field, value)?,
676                Err(e) => {
677                    instance.common.putf = false;
678                    return Err(e);
679                }
680            };
681
682            // Invalidate metadata cache only if the metadata-class
683            // field's value actually changed (faac1df1).
684            instance.notify_field_written_if_changed(&field, prev_value.as_ref());
685
686            // C `dbAccess.c::dbPutField:1276` sets `precord->putf = TRUE`
687            // immediately before calling `dbProcess`, and the flag stays
688            // TRUE through the entire process cycle. It is cleared only
689            // in `recGblFwdLink` (recGbl.c:302) after FLNK fires, OR in
690            // the disable-alarm bail (dbAccess.c:576). The Rust port
691            // previously cleared `putf` here — BEFORE the
692            // `process_record_with_links` call below — so any code
693            // path (TPRO trace, async-completion logic, monitor on
694            // .PUTF) observing the bit during the process cycle saw
695            // `putf=0` and could not distinguish put-driven vs
696            // scan-driven processing.
697            //
698            // DO NOT clear `putf` here. The clearing now happens after
699            // the process call returns (synchronous completion) or in
700            // `complete_async_record` (async completion).
701
702            instance.cleanup_subscribers();
703            // C `dbPut:1408-1414` posts DBE_VALUE|DBE_LOG for the put field
704            // unless `(isValueField && pfldDes->process_passive)` — the
705            // immediate post is suppressed for the value field ONLY when that
706            // field is `pp(TRUE)`, because then the reprocess cycle
707            // (`dbPutField:1265-1268`) re-posts it via the deadband snapshot.
708            // For a value field that is NOT `pp` (calc/calcout/aSub VAL), C
709            // posts here and does not reprocess; the port must do the same,
710            // because the `should_process` gate below skips the cycle for a
711            // non-`pp` value field — without this post a direct VAL put would
712            // fire no monitor at all.
713            let suppress_value_field_post = field == instance.record.primary_field()
714                && match instance.record.process_passive_fields() {
715                    Some(pp) => pp.iter().any(|f| f.eq_ignore_ascii_case(&field)),
716                    // Un-modeled record types keep the legacy "process on every
717                    // put" behavior (`should_process = true` below), so the
718                    // reprocess cycle posts the value field — suppress the
719                    // immediate post here to avoid a duplicate event.
720                    None => true,
721                };
722            if !suppress_value_field_post {
723                instance.notify_field(
724                    &field,
725                    crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
726                );
727            }
728
729            // Fields a `special()` changed as a side effect of this put
730            // (e.g. compress RES reset zeroing NUSE/VAL) get their monitors
731            // posted here, mirroring the explicit `db_post_events` a C
732            // `special()` makes — these fields are not pp(TRUE), so no
733            // process cycle would otherwise post them.
734            for sf in instance.record.monitor_side_effect_fields(&field) {
735                instance.notify_field(
736                    sf,
737                    crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
738                );
739            }
740
741            common_result
742        };
743        // ASG-field change re-evaluation hook. C
744        // `asDbLib.c:107-110,144` `asSpcAsCallback` invokes
745        // `asChangeGroup` → `asAddMemberPvt` → `asComputePvt` for
746        // every `ASGCLIENT` on `dbPut record.ASG NEW_ASG`. Pre-fix
747        // Rust mutated `common.asg` directly with no notification,
748        // so the wire ACCESS_RIGHTS the client saw still reflected
749        // the OLD ASG until something else triggered re-eval. Now we
750        // fire a process-wide notifier that the CA server folds into
751        // its per-client `reeval_access_rights` path.
752        if field == "ASG" {
753            crate::server::access_security::notify_asg_field_changed();
754        }
755        // record lock released
756
757        // Update scan index if SCAN or PHAS changed
758        match common_result {
759            crate::server::record::CommonFieldPutResult::ScanChanged {
760                old_scan,
761                new_scan,
762                phas,
763            } => {
764                self.update_scan_index(record_name, old_scan, new_scan, phas, phas)
765                    .await;
766            }
767            crate::server::record::CommonFieldPutResult::PhasChanged {
768                scan: s,
769                old_phas,
770                new_phas,
771            } => {
772                self.update_scan_index(record_name, s, s, old_phas, new_phas)
773                    .await;
774            }
775            crate::server::record::CommonFieldPutResult::NoChange => {}
776        }
777
778        // C `dbAccess.c::dbPutField:1263-1268` re-processes the
779        // record on a put only when the put field is `pp(TRUE)` AND the
780        // record is Passive (`SCAN == 0`). (The `PROC` field has its own
781        // always-process intercept above, matching C's
782        // `pfield == &precord->proc`; alarm-ack fields like ACKT/ACKS are
783        // not `pp(TRUE)` so they fall out here, matching C's
784        // `dbrType < DBR_PUT_ACKT`.) Processing on every put would
785        // double-process scanned records and spuriously process puts to
786        // non-`pp` fields (extra FLNK / monitors / device writes /
787        // timestamps). A record type whose DBD pp-flags are not modeled
788        // returns `None` and keeps the legacy "process on every put"
789        // behavior so un-modeled types (other crates, tests) are unchanged.
790        let should_process = {
791            let instance = rec.read().await;
792            match instance.record.process_passive_fields() {
793                Some(pp) => {
794                    instance.common.scan == crate::server::record::ScanType::Passive
795                        && pp.iter().any(|f| f.eq_ignore_ascii_case(&field))
796                }
797                None => true,
798            }
799        };
800
801        if !should_process {
802            // No processing cycle. C never sets `putf` on this path, so
803            // clear the flag the field-put set at entry, and report
804            // immediate (synchronous) completion to a WRITE_NOTIFY caller.
805            let recs = self.inner.records.read().await;
806            if let Some(rec_arc) = recs.get(record_name) {
807                let mut guard = rec_arc.write().await;
808                if !guard.is_processing() {
809                    guard.common.putf = false;
810                }
811            }
812            return Ok(None);
813        }
814
815        // Set up the put-notify wait-set BEFORE processing. The wait-set
816        // fires `completion_tx` only after the originating record AND
817        // every FLNK/OUT chain target it triggers (sync or async) has
818        // completed — C `dbNotify.c` `processNotify`/`dbNotifyCompletion`.
819        // Refuse a second concurrent WRITE_NOTIFY on the same record:
820        // C EPICS returns S_db_Blocked / ECA_PUTCBINPROG, and silently
821        // overwriting the wait-set would drop the prior Sender, waking
822        // the prior caller's rx with RecvError that the CA dispatcher
823        // treats as success.
824        let (completion_tx, completion_rx) = crate::runtime::sync::oneshot::channel();
825        let notify = crate::server::record::NotifyWaitSet::new(completion_tx);
826        {
827            let rec = self.inner.records.read().await;
828            if let Some(rec_arc) = rec.get(record_name) {
829                let mut guard = rec_arc.write().await;
830                if guard.notify.is_some() {
831                    return Err(CaError::PutCallbackInProgress(record_name.to_string()));
832                }
833                guard.notify = Some(notify.clone());
834            }
835        }
836
837        // When a CA put writes directly to VAL on an INPUT record whose
838        // VAL is the engineering value, the built-in `RVAL → VAL`
839        // `convert()` must be suppressed for the put-driven process —
840        // re-deriving VAL from a stale RVAL would clobber the value the
841        // operator just wrote (the soft ai preset-NaN case, processing.rs
842        // ~line 677). The framework expresses this by calling
843        // `set_device_did_compute(true)`.
844        //
845        // This MUST be gated on `soft_channel_skips_convert()`. Output
846        // records (mbbo/mbbo_direct/bo/ao) implement
847        // `set_device_did_compute` as "skip the VAL → RVAL output
848        // convert" — the OPPOSITE direction. C `mbboRecord.c::process`
849        // (line 217), `mbboDirectRecord.c::process` (line 198) and
850        // `boRecord.c::process` (line 207) call `convert()`
851        // unconditionally on every non-pact process; a CA VAL-put on an
852        // output record MUST recompute RVAL/ORAW. Suppressing it there
853        // left RVAL/ORAW/ORBV stale. Output records return the default
854        // `false` from `soft_channel_skips_convert()`, so this gate
855        // matches the identical gates in processing.rs (line 694) and
856        // record_instance.rs (line 1381).
857        if field == "VAL" {
858            let recs = self.inner.records.read().await;
859            if let Some(rec_arc) = recs.get(record_name) {
860                let mut guard = rec_arc.write().await;
861                if guard.record.soft_channel_skips_convert() {
862                    guard.record.set_device_did_compute(true);
863                }
864            }
865        }
866
867        // Process the record after field put.
868        {
869            let mut visited = HashSet::new();
870            // `record_name`'s advisory write gate is already
871            // held by this `put` (the `_record_gate` taken above, or
872            // the QSRV atomic group's `lock_records` epoch via
873            // `put_record_field_from_ca_already_locked`). The gate
874            // `Mutex` is not reentrant — use the `_already_locked`
875            // processing entry.
876            let _ = self
877                .process_record_with_links_already_locked(record_name, &mut visited, 0)
878                .await;
879        }
880
881        // Is the ORIGINATING record itself still async-pending? Its
882        // wait-set membership is taken + `leave`d at its own completion
883        // (sync-end, or later in `complete_async_record_inner`), so a
884        // lingering `notify` on its instance means its device round-trip
885        // is still in flight. This gates only the originating record's
886        // PUTF clear — independent of whether downstream chain targets
887        // are still pending.
888        let originating_pending = {
889            let rec = self.inner.records.read().await;
890            if let Some(rec_arc) = rec.get(record_name) {
891                rec_arc.read().await.notify.is_some()
892            } else {
893                false
894            }
895        };
896
897        // C `recGbl.c::recGblFwdLink:302` clears `putf = FALSE` after
898        // the forward-link dispatch — the marker only lives for the
899        // duration of the put's processing cycle. For SYNCHRONOUS
900        // completions (PACT was cleared by the time
901        // `process_record_with_links` returns) clear it here. For
902        // async-pending records, the clearing happens later in
903        // `complete_async_record_inner` (which runs FLNK as part of
904        // the completion path) so the PUTF marker survives the
905        // device-write round trip.
906        if !originating_pending {
907            let rec = self.inner.records.read().await;
908            if let Some(rec_arc) = rec.get(record_name) {
909                let mut guard = rec_arc.write().await;
910                if !guard.is_processing() {
911                    guard.common.putf = false;
912                }
913            }
914        }
915
916        // CA completion gates on the WHOLE chain, not just the
917        // originating record: the put-notify must not report
918        // done until every FLNK/OUT target it drove — including an async
919        // FLNK target that the originating record's sync cycle merely
920        // kicked off — has settled. `completed()` is true iff the
921        // wait-set drained to zero during this call (fully synchronous
922        // chain); otherwise the receiver fires later from the last
923        // chain member's `leave`.
924        if notify.completed() {
925            Ok(None)
926        } else {
927            Ok(Some(completion_rx))
928        }
929    }
930
931    /// Put a PV value without triggering process (for restore).
932    pub async fn put_pv_no_process(&self, name: &str, value: EpicsValue) -> CaResult<()> {
933        let (base, field) = super::parse_pv_name(name);
934        let field = field.to_ascii_uppercase();
935
936        if let Some(pv) = self.inner.simple_pvs.read().await.get(name) {
937            pv.set(value).await;
938            return Ok(());
939        }
940
941        // Records — alias-aware (epics-base PR #336).
942        if let Some(rec) = self.get_record(base).await {
943            // `put_pv_no_process` is a public record-write API
944            // (autosave restore). It must take the advisory write gate
945            // (`dbScanLock` analogue) so an autosave restore cannot
946            // land between the member writes of a QSRV atomic group or
947            // a pvalink atomic scan epoch holding `lock_records`.
948            // `base` is alias-resolved so an alias and its target
949            // share one gate. Held until return.
950            let canonical_base: String = self
951                .resolve_alias(base)
952                .await
953                .unwrap_or_else(|| base.to_string());
954            let _record_gate = self.lock_record(&canonical_base).await;
955
956            let mut instance = rec.write().await;
957            let prev_value = instance.record.get_field(&field);
958            match instance.record.put_field(&field, value.clone()) {
959                Ok(()) => {}
960                Err(CaError::FieldNotFound(_)) => {
961                    instance.put_common_field(&field, value)?;
962                }
963                Err(e) => return Err(e),
964            }
965            // Invalidate metadata cache only if the metadata-class
966            // field actually changed (faac1df1).
967            instance.notify_field_written_if_changed(&field, prev_value.as_ref());
968            // same SPC_AS parity as `put_pv` / the CA-write
969            // path — autosave-style restores writing `.ASG` at IOC
970            // startup must still trigger per-client re-eval.
971            if field == "ASG" {
972                crate::server::access_security::notify_asg_field_changed();
973            }
974            return Ok(());
975        }
976
977        Err(CaError::ChannelNotFound(name.to_string()))
978    }
979}
980
981/// Project a decoded `DBR_CTRL_*` / `DBR_GR_*` snapshot's metadata fields
982/// (display / control limits, enum labels) into the shadow-PV
983/// [`PvMetadata`](crate::server::pv::PvMetadata) the CA gateway installs.
984/// A non-metadata (TIME/STS) snapshot carries `None` in all three, which
985/// clears the shadow metadata — but the gateway only ever feeds this a
986/// control-class snapshot, matching C `setPvData` replacing the attribute
987/// gdd wholesale from the control get/event.
988fn metadata_from_snapshot(snapshot: &Snapshot) -> crate::server::pv::PvMetadata {
989    crate::server::pv::PvMetadata {
990        display: snapshot.display.clone(),
991        control: snapshot.control.clone(),
992        enums: snapshot.enums.clone(),
993    }
994}
995
996#[cfg(test)]
997mod tests {
998    use super::super::PvDatabase;
999    use crate::types::EpicsValue;
1000
1001    /// Regression: prior to fixing B1, `put_pv_and_post` walked only
1002    /// `inner.records` and returned `ChannelNotFound` for everything
1003    /// `add_pv`-registered. The CA gateway's monitor forwarder uses
1004    /// `add_pv` then expects `put_pv_and_post` to fan-out to
1005    /// downstream subscribers — without the simple-PV branch, every
1006    /// upstream event was silently dropped and the gateway delivered
1007    /// no monitors.
1008    #[tokio::test]
1009    async fn put_pv_and_post_handles_simple_pv() {
1010        let db = PvDatabase::new();
1011        db.add_pv("gw:test", EpicsValue::Double(0.0)).await.unwrap();
1012
1013        // Should NOT return ChannelNotFound.
1014        db.put_pv_and_post("gw:test", EpicsValue::Double(42.0))
1015            .await
1016            .expect("simple PV put_pv_and_post must succeed");
1017
1018        // Value actually landed.
1019        let pv = db.find_pv("gw:test").await.expect("PV exists");
1020        assert!(matches!(pv.get().await, EpicsValue::Double(v) if v == 42.0));
1021    }
1022
1023    /// Regression: `get_pv`, `put_pv`, `put_pv_and_post`,
1024    /// and `put_pv_no_process` all bypassed `get_record` and walked
1025    /// `self.inner.records` directly, so alias names from epics-base
1026    /// PR #336 silently returned `ChannelNotFound`. A later fix closed
1027    /// `get_record` but the same defect was hiding in field_io.rs.
1028    /// All four CA-server-and-bridge entry points must accept aliases.
1029    #[tokio::test]
1030    async fn field_io_entry_points_accept_aliases() {
1031        use crate::server::records::ai::AiRecord;
1032
1033        let db = PvDatabase::new();
1034        db.add_record("CANON", Box::new(AiRecord::new(0.0)))
1035            .await
1036            .unwrap();
1037        db.add_alias("ALT", "CANON").await.unwrap();
1038
1039        // get_pv via alias
1040        db.put_pv("CANON.VAL", EpicsValue::Double(1.5))
1041            .await
1042            .unwrap();
1043        let v = db.get_pv("ALT.VAL").await.unwrap();
1044        assert!(matches!(v, EpicsValue::Double(x) if x == 1.5));
1045
1046        // put_pv via alias
1047        db.put_pv("ALT.VAL", EpicsValue::Double(7.0)).await.unwrap();
1048        let v = db.get_pv("CANON.VAL").await.unwrap();
1049        assert!(matches!(v, EpicsValue::Double(x) if x == 7.0));
1050
1051        // put_pv_and_post via alias
1052        db.put_pv_and_post("ALT.VAL", EpicsValue::Double(11.0))
1053            .await
1054            .unwrap();
1055        let v = db.get_pv("CANON.VAL").await.unwrap();
1056        assert!(matches!(v, EpicsValue::Double(x) if x == 11.0));
1057
1058        // put_pv_no_process via alias
1059        db.put_pv_no_process("ALT.VAL", EpicsValue::Double(13.0))
1060            .await
1061            .unwrap();
1062        let v = db.get_pv("ALT.VAL").await.unwrap();
1063        assert!(matches!(v, EpicsValue::Double(x) if x == 13.0));
1064    }
1065
1066    /// `set_pv_metadata` installs the upstream `DBR_CTRL_*` metadata on a
1067    /// shadow simple PV WITHOUT posting any event (the CA gateway's
1068    /// connect-time seed). A later GET-class read must then see the
1069    /// installed limits/units, and a `DBE_PROPERTY` subscriber must NOT
1070    /// have received anything (nothing *changed* yet). An unknown / record
1071    /// name is rejected with `ChannelNotFound`.
1072    #[tokio::test]
1073    async fn set_pv_metadata_installs_without_posting() {
1074        use crate::error::CaError;
1075        use crate::server::snapshot::{DisplayInfo, Snapshot};
1076        use crate::types::DbFieldType;
1077        use std::time::SystemTime;
1078
1079        let db = PvDatabase::new();
1080        db.add_pv("gw:meta", EpicsValue::Double(0.0)).await.unwrap();
1081
1082        // A DBE_PROPERTY subscriber attached BEFORE the seed — it must stay
1083        // empty, because seeding metadata is not a property *change*.
1084        const DBE_PROPERTY: u16 = 8;
1085        let pv = db.find_pv("gw:meta").await.expect("PV exists");
1086        let mut prop_rx = pv
1087            .add_subscriber(1, DbFieldType::Double, DBE_PROPERTY)
1088            .await
1089            .expect("subscriber added");
1090
1091        // Build a CTRL-class snapshot carrying display metadata.
1092        let mut ctrl = Snapshot::new(EpicsValue::Double(0.0), 0, 0, SystemTime::UNIX_EPOCH);
1093        ctrl.display = Some(DisplayInfo {
1094            units: "mm".into(),
1095            precision: 3,
1096            upper_disp_limit: 10.0,
1097            lower_disp_limit: -10.0,
1098            ..Default::default()
1099        });
1100
1101        db.set_pv_metadata("gw:meta", &ctrl)
1102            .await
1103            .expect("simple PV set_pv_metadata must succeed");
1104
1105        // The metadata landed on the shadow PV.
1106        let installed = pv.metadata();
1107        assert_eq!(
1108            installed.display.expect("display metadata installed").units,
1109            "mm"
1110        );
1111
1112        // No event was posted (seed != change).
1113        assert!(
1114            prop_rx.try_recv().is_err(),
1115            "set_pv_metadata must not post a DBE_PROPERTY event"
1116        );
1117
1118        // Unknown / non-simple PV is rejected.
1119        assert!(matches!(
1120            db.set_pv_metadata("no:such:pv", &ctrl).await,
1121            Err(CaError::ChannelNotFound(_))
1122        ));
1123    }
1124
1125    /// `post_pv_property` refreshes the shadow metadata AND posts a
1126    /// `DBE_PROPERTY` event carrying the supplied snapshot's metadata,
1127    /// upstream status/severity, and (undefined control-DBR) timestamp — to
1128    /// `DBE_PROPERTY` subscribers only. This is the DB-routing layer the
1129    /// gateway's property monitor drives on every upstream `DBE_PROPERTY`
1130    /// event. An unknown / record name is rejected with `ChannelNotFound`.
1131    #[tokio::test]
1132    async fn post_pv_property_refreshes_and_posts_property_event() {
1133        use crate::error::CaError;
1134        use crate::server::snapshot::{DisplayInfo, Snapshot};
1135        use crate::types::{DbFieldType, WallTime};
1136
1137        const DBE_PROPERTY: u16 = 8;
1138        const DBE_VALUE: u16 = 1;
1139        const MAJOR: u16 = 2;
1140        const HIGH: u16 = 3;
1141
1142        let db = PvDatabase::new();
1143        db.add_pv("gw:prop", EpicsValue::Double(0.0)).await.unwrap();
1144        let pv = db.find_pv("gw:prop").await.expect("PV exists");
1145
1146        let mut prop_rx = pv
1147            .add_subscriber(1, DbFieldType::Double, DBE_PROPERTY)
1148            .await
1149            .expect("property subscriber added");
1150        let mut val_rx = pv
1151            .add_subscriber(2, DbFieldType::Double, DBE_VALUE)
1152            .await
1153            .expect("value subscriber added");
1154
1155        // Upstream CTRL event: metadata + MAJOR/HIGH alarm + a fixed past
1156        // timestamp that is unmistakably not a fresh wall clock.
1157        let upstream_ts = WallTime::from_unix(2_000_000, 0);
1158        let mut ctrl = Snapshot::new(EpicsValue::Double(5.0), HIGH, MAJOR, upstream_ts);
1159        ctrl.display = Some(DisplayInfo {
1160            units: "V".into(),
1161            precision: 1,
1162            ..Default::default()
1163        });
1164
1165        db.post_pv_property("gw:prop", ctrl)
1166            .await
1167            .expect("simple PV post_pv_property must succeed");
1168
1169        // The metadata was refreshed on the shadow PV.
1170        assert_eq!(
1171            pv.metadata().display.expect("metadata refreshed").units,
1172            "V"
1173        );
1174
1175        // The DBE_PROPERTY subscriber received the metadata-bearing event,
1176        // with the upstream alarm and timestamp preserved.
1177        let ev = prop_rx
1178            .try_recv()
1179            .expect("DBE_PROPERTY subscriber receives the property event");
1180        assert_eq!(
1181            ev.snapshot.display.expect("event carries metadata").units,
1182            "V"
1183        );
1184        assert_eq!(
1185            ev.snapshot.alarm.severity, MAJOR,
1186            "upstream severity preserved"
1187        );
1188        assert_eq!(ev.snapshot.alarm.status, HIGH, "upstream status preserved");
1189        assert_eq!(
1190            ev.snapshot.timestamp, upstream_ts,
1191            "control-DBR timestamp preserved, not a fresh wall clock"
1192        );
1193
1194        // The DBE_VALUE-only subscriber must NOT receive a property event.
1195        assert!(
1196            val_rx.try_recv().is_err(),
1197            "DBE_VALUE-only subscriber must not receive a property post"
1198        );
1199
1200        // Unknown / non-simple PV is rejected.
1201        let again = Snapshot::new(EpicsValue::Double(0.0), 0, 0, WallTime::UNIX_EPOCH);
1202        assert!(matches!(
1203            db.post_pv_property("no:such:pv", again).await,
1204            Err(CaError::ChannelNotFound(_))
1205        ));
1206    }
1207
1208    /// Regression: `put_record_field_from_ca` (the CA
1209    /// server's main put fast path) must accept aliases. Pre-fix it
1210    /// only consulted `inner.records` directly. Also exercises the
1211    /// canonical-name normalisation that protects subsequent
1212    /// `process_record_with_links` / `update_scan_index` calls.
1213    #[tokio::test]
1214    async fn put_record_field_from_ca_accepts_alias() {
1215        use crate::server::records::ai::AiRecord;
1216
1217        let db = PvDatabase::new();
1218        db.add_record("CANON", Box::new(AiRecord::new(0.0)))
1219            .await
1220            .unwrap();
1221        db.add_alias("ALT", "CANON").await.unwrap();
1222
1223        // Put VAL via the alias name.
1224        let _ = db
1225            .put_record_field_from_ca("ALT", "VAL", EpicsValue::Double(2.5))
1226            .await
1227            .expect("put via alias must succeed");
1228
1229        // Read back via canonical to confirm the value landed on the
1230        // right record.
1231        let v = db.get_pv("CANON.VAL").await.unwrap();
1232        assert!(matches!(v, EpicsValue::Double(x) if x == 2.5));
1233    }
1234
1235    /// Regression: a direct CA put to a record whose value field VAL is NOT
1236    /// `pp(TRUE)` (calc / calcout / aSub) must still fire a DBE_VALUE monitor.
1237    /// C `dbAccess.c::dbPut:1408-1414` posts the value field immediately
1238    /// unless it is `pp(TRUE)`. The port previously suppressed the immediate
1239    /// post for every `VAL` and — with the `should_process` gate — skipped
1240    /// the reprocess cycle for a non-`pp` VAL, so the operator's write fired
1241    /// no monitor at all. calc's VAL is not in its `pp` field set, so the
1242    /// immediate post is the only event that can fire.
1243    #[tokio::test]
1244    async fn ca_put_to_non_pp_val_posts_monitor() {
1245        use crate::server::database::db_access::DbSubscription;
1246        use crate::server::records::calc::CalcRecord;
1247
1248        let db = PvDatabase::new();
1249        db.add_record("CALC1", Box::new(CalcRecord::new("0")))
1250            .await
1251            .unwrap();
1252
1253        let mut sub = DbSubscription::subscribe(&db, "CALC1.VAL")
1254            .await
1255            .expect("subscribe to CALC1.VAL");
1256
1257        db.put_record_field_from_ca("CALC1", "VAL", EpicsValue::Double(5.0))
1258            .await
1259            .expect("CA put to CALC1.VAL must succeed");
1260
1261        let got = tokio::time::timeout(std::time::Duration::from_secs(1), sub.recv_f64())
1262            .await
1263            .expect("a DBE_VALUE monitor must fire for a direct VAL put to a non-pp record");
1264        assert_eq!(got, Some(5.0));
1265    }
1266
1267    /// Record-backed consumer half of the source-coalesced stale-tail rule.
1268    ///
1269    /// `DbSubscription::next_event` shares the `coalesce_consume` ordering
1270    /// rule with `PvSubscription`: a record-field monitor whose bounded
1271    /// queue overflows mid-burst must converge on the newest value and
1272    /// never step back to an older queued one. Each non-pp `VAL` put posts
1273    /// exactly one DBE_VALUE monitor with the put value and does NOT
1274    /// reprocess (see `ca_put_to_non_pp_val_posts_monitor`), so 80 distinct
1275    /// puts produce a strictly increasing 1..=80 stream; with no consumer
1276    /// draining, 1..=64 fill the queue and the newest (80) lands in the
1277    /// coalesce slot.
1278    ///
1279    /// Before the fix `next_event` returned the coalesced `80` and then
1280    /// replayed the stale tail `1..=64` (`80, 1, 2, ...`) — value time
1281    /// going backwards.
1282    #[tokio::test]
1283    async fn r0604_db_overflow_never_delivers_newest_then_old() {
1284        use crate::server::database::db_access::DbSubscription;
1285        use crate::server::records::calc::CalcRecord;
1286
1287        let db = PvDatabase::new();
1288        db.add_record("CALC1", Box::new(CalcRecord::new("0")))
1289            .await
1290            .unwrap();
1291        let mut sub = DbSubscription::subscribe(&db, "CALC1.VAL")
1292            .await
1293            .expect("subscribe to CALC1.VAL");
1294
1295        for i in 1..=80u32 {
1296            db.put_record_field_from_ca("CALC1", "VAL", EpicsValue::Double(i as f64))
1297                .await
1298                .expect("CA put to CALC1.VAL must succeed");
1299        }
1300
1301        // Drain every immediately-available delivery; the recv past the
1302        // last event has nothing queued and times out, ending collection.
1303        let mut seq = Vec::new();
1304        while let Ok(Some(v)) =
1305            tokio::time::timeout(std::time::Duration::from_millis(200), sub.recv_f64()).await
1306        {
1307            seq.push(v);
1308        }
1309        assert!(!seq.is_empty(), "consumer must observe at least one value");
1310        for w in seq.windows(2) {
1311            assert!(
1312                w[0] <= w[1],
1313                "record monitor delivery stepped backward {} -> {} (sequence {seq:?})",
1314                w[0],
1315                w[1],
1316            );
1317        }
1318        assert_eq!(
1319            *seq.last().unwrap(),
1320            80.0,
1321            "record consumer must converge on the newest produced value (sequence {seq:?})"
1322        );
1323    }
1324}