use std::collections::HashSet;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use spvirit_codec::spvd_decode::{DecodedValue, StructureDesc};
use spvirit_types::NtPayload;
use tokio::sync::{RwLock, mpsc};
use tracing::debug;
#[derive(Debug, Clone)]
pub struct PvInfo {
pub descriptor: StructureDesc,
pub writable: bool,
}
pub trait Source: Send + Sync {
fn claim(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<PvInfo>> + Send + '_>>;
fn get(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<NtPayload>> + Send + '_>>;
fn put(
&self,
name: &str,
value: &DecodedValue,
) -> Pin<Box<dyn Future<Output = Result<Vec<(String, NtPayload)>, String>> + Send + '_>>;
fn subscribe(
&self,
name: &str,
) -> Pin<Box<dyn Future<Output = Option<mpsc::Receiver<NtPayload>>> + Send + '_>>;
fn rpc(
&self,
_name: &str,
_args: &DecodedValue,
) -> Pin<Box<dyn Future<Output = Result<NtPayload, String>> + Send + '_>> {
Box::pin(async { Err("RPC not supported".to_string()) })
}
fn names(&self) -> Pin<Box<dyn Future<Output = Vec<String>> + Send + '_>>;
}
pub trait StoreSource: Source {
fn record_names(&self) -> Vec<String>;
}
struct SourceEntry {
label: String,
order: i32,
source: Arc<dyn Source>,
is_store: bool,
}
pub struct SourceRegistry {
sources: RwLock<Vec<SourceEntry>>,
shadow_checked: RwLock<HashSet<String>>,
}
impl SourceRegistry {
pub fn new() -> Self {
Self {
sources: RwLock::new(Vec::new()),
shadow_checked: RwLock::new(HashSet::new()),
}
}
pub async fn add(&self, label: impl Into<String>, order: i32, source: Arc<dyn Source>) {
self.insert(label.into(), order, source, false).await;
}
pub async fn add_store(&self, label: impl Into<String>, order: i32, source: Arc<dyn Source>) {
self.insert(label.into(), order, source, true).await;
}
async fn insert(&self, label: String, order: i32, source: Arc<dyn Source>, is_store: bool) {
debug!(
"SourceRegistry: adding source '{}' at order {} (store: {})",
label, order, is_store
);
let mut sources = self.sources.write().await;
sources.push(SourceEntry {
label,
order,
source,
is_store,
});
sources.sort_by_key(|e| e.order);
}
pub async fn remove(&self, label: &str) {
debug!("SourceRegistry: removing source '{}'", label);
let mut sources = self.sources.write().await;
sources.retain(|e| e.label != label);
}
pub async fn claim(&self, name: &str) -> Option<PvInfo> {
let sources = self.sources.read().await;
for entry in sources.iter() {
if let Some(info) = entry.source.claim(name).await {
if !entry.is_store {
self.warn_if_shadowing_a_store(&sources, &entry.label, name)
.await;
}
return Some(info);
}
}
None
}
async fn warn_if_shadowing_a_store(&self, sources: &[SourceEntry], winner: &str, name: &str) {
if self.shadow_checked.read().await.contains(name) {
return;
}
if !self.shadow_checked.write().await.insert(name.to_string()) {
return;
}
for entry in sources.iter().filter(|e| e.is_store) {
if entry.source.claim(name).await.is_some() {
tracing::warn!(
"source '{winner}' shadows store '{}' for PV '{name}': the store's \
value will never be served",
entry.label
);
return;
}
}
}
pub async fn has_pv(&self, name: &str) -> bool {
self.claim(name).await.is_some()
}
pub async fn get(&self, name: &str) -> Option<NtPayload> {
let sources = self.sources.read().await;
for entry in sources.iter() {
if entry.source.claim(name).await.is_some() {
return entry.source.get(name).await;
}
}
None
}
pub async fn get_descriptor(&self, name: &str) -> Option<StructureDesc> {
self.claim(name).await.map(|info| info.descriptor)
}
pub async fn is_writable(&self, name: &str) -> bool {
self.claim(name).await.is_some_and(|info| info.writable)
}
pub async fn put(
&self,
name: &str,
value: &DecodedValue,
) -> Result<Vec<(String, NtPayload)>, String> {
let sources = self.sources.read().await;
for entry in sources.iter() {
if entry.source.claim(name).await.is_some() {
return entry.source.put(name, value).await;
}
}
Err(format!("PV '{}' not found", name))
}
pub async fn subscribe(&self, name: &str) -> Option<mpsc::Receiver<NtPayload>> {
let sources = self.sources.read().await;
for entry in sources.iter() {
if entry.source.claim(name).await.is_some() {
return entry.source.subscribe(name).await;
}
}
None
}
pub async fn rpc(&self, name: &str, args: &DecodedValue) -> Result<NtPayload, String> {
let sources = self.sources.read().await;
for entry in sources.iter() {
if entry.source.claim(name).await.is_some() {
return entry.source.rpc(name, args).await;
}
}
Err(format!("RPC channel '{}' not found", name))
}
pub async fn names(&self) -> Vec<String> {
let sources = self.sources.read().await;
let mut seen = HashSet::new();
let mut all = Vec::new();
for entry in sources.iter() {
for name in entry.source.names().await {
if seen.insert(name.clone()) {
all.push(name);
}
}
}
all.sort();
all
}
}
impl Default for SourceRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
struct StubSource {
names: Vec<String>,
claims: std::sync::atomic::AtomicUsize,
}
impl StubSource {
fn new(names: &[&str]) -> Self {
Self {
names: names.iter().map(|s| s.to_string()).collect(),
claims: std::sync::atomic::AtomicUsize::new(0),
}
}
fn claim_count(&self) -> usize {
self.claims.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl Source for StubSource {
fn claim(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<PvInfo>> + Send + '_>> {
self.claims.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let claimed = self.names.iter().any(|n| n == name);
Box::pin(async move {
claimed.then(|| PvInfo {
descriptor: StructureDesc::default(),
writable: true,
})
})
}
fn get(&self, _name: &str) -> Pin<Box<dyn Future<Output = Option<NtPayload>> + Send + '_>> {
Box::pin(async { None })
}
fn put(
&self,
_name: &str,
_value: &DecodedValue,
) -> Pin<Box<dyn Future<Output = Result<Vec<(String, NtPayload)>, String>> + Send + '_>>
{
Box::pin(async { Ok(vec![]) })
}
fn subscribe(
&self,
_name: &str,
) -> Pin<Box<dyn Future<Output = Option<mpsc::Receiver<NtPayload>>> + Send + '_>> {
Box::pin(async { None })
}
fn names(&self) -> Pin<Box<dyn Future<Output = Vec<String>> + Send + '_>> {
let names = self.names.clone();
Box::pin(async move { names })
}
}
#[tokio::test]
async fn stores_are_recorded_as_stores_and_sources_are_not() {
let reg = SourceRegistry::new();
reg.add_store("builtin", 0, Arc::new(StubSource::new(&["A"]))).await;
reg.add("custom", 10, Arc::new(StubSource::new(&["B"]))).await;
let flags: Vec<(String, bool)> = reg
.sources
.read()
.await
.iter()
.map(|e| (e.label.clone(), e.is_store))
.collect();
assert_eq!(
flags,
vec![("builtin".to_string(), true), ("custom".to_string(), false)]
);
}
#[tokio::test]
async fn a_store_added_late_still_sorts_by_order() {
let reg = SourceRegistry::new();
reg.add("custom", 10, Arc::new(StubSource::new(&["B"]))).await;
reg.add_store("builtin", 0, Arc::new(StubSource::new(&["A"]))).await;
let labels: Vec<String> = reg
.sources
.read()
.await
.iter()
.map(|e| e.label.clone())
.collect();
assert_eq!(labels, vec!["builtin".to_string(), "custom".to_string()]);
}
#[tokio::test]
async fn a_source_shadowing_a_store_still_wins_the_claim() {
let reg = SourceRegistry::new();
reg.add("override", -1, Arc::new(StubSource::new(&["PV:X"]))).await;
reg.add_store("builtin", 0, Arc::new(StubSource::new(&["PV:X"]))).await;
assert!(reg.claim("PV:X").await.is_some());
}
#[tokio::test]
async fn the_shadow_check_runs_once_per_pv() {
let reg = SourceRegistry::new();
let store = Arc::new(StubSource::new(&["PV:X"]));
reg.add("override", -1, Arc::new(StubSource::new(&["PV:X"]))).await;
reg.add_store("builtin", 0, store.clone()).await;
let before = store.claim_count();
for _ in 0..5 {
reg.claim("PV:X").await;
}
assert_eq!(
store.claim_count() - before,
1,
"the shadowed store must be consulted exactly once"
);
}
#[tokio::test]
async fn an_unshadowed_source_claim_is_also_checked_only_once() {
let reg = SourceRegistry::new();
let store = Arc::new(StubSource::new(&["PV:OTHER"]));
reg.add("plain", -1, Arc::new(StubSource::new(&["PV:X"]))).await;
reg.add_store("builtin", 0, store.clone()).await;
let before = store.claim_count();
for _ in 0..5 {
reg.claim("PV:X").await;
}
assert_eq!(store.claim_count() - before, 1);
}
#[tokio::test]
async fn a_store_winning_its_own_claim_consults_nothing_else() {
let reg = SourceRegistry::new();
let other = Arc::new(StubSource::new(&["PV:X"]));
reg.add_store("builtin", 0, Arc::new(StubSource::new(&["PV:X"]))).await;
reg.add_store("second", 5, other.clone()).await;
let before = other.claim_count();
reg.claim("PV:X").await;
assert_eq!(other.claim_count() - before, 0);
}
#[derive(Clone, Default)]
struct CaptureWriter(Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for CaptureWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn the_shadow_warning_is_emitted_once_not_just_counted() {
let buffer = CaptureWriter::default();
let writer = buffer.clone();
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::WARN)
.with_ansi(false)
.without_time()
.with_writer(move || writer.clone())
.finish();
let _subscriber_guard = tracing::subscriber::set_default(subscriber);
let reg = SourceRegistry::new();
reg.add("override", -1, Arc::new(StubSource::new(&["PV:X"]))).await;
reg.add_store("builtin", 0, Arc::new(StubSource::new(&["PV:X"]))).await;
reg.claim("PV:X").await;
reg.claim("PV:X").await;
let captured = String::from_utf8(buffer.0.lock().unwrap().clone()).unwrap();
let warnings: Vec<&str> = captured.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
warnings.len(),
1,
"expected exactly one warning event, got: {captured:?}"
);
assert!(warnings[0].contains("PV:X"), "missing PV name: {captured:?}");
assert!(warnings[0].contains("override"), "missing source label: {captured:?}");
assert!(warnings[0].contains("builtin"), "missing store label: {captured:?}");
}
}