use async_trait::async_trait;
use futures::FutureExt;
use std::collections::BTreeMap;
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginError {
AlreadyRegistered(String),
NotFound(String),
}
impl std::fmt::Display for PluginError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AlreadyRegistered(name) => write!(f, "plugin already registered: {name}"),
Self::NotFound(name) => write!(f, "plugin not found: {name}"),
}
}
}
impl std::error::Error for PluginError {}
#[derive(Debug, Clone)]
pub struct PluginEvent {
pub key: String,
pub rule_id: Option<String>,
pub reason: String,
}
impl PluginEvent {
pub fn new(key: impl Into<String>, rule_id: Option<String>, reason: impl Into<String>) -> Self {
Self {
key: key.into(),
rule_id,
reason: reason.into(),
}
}
}
#[async_trait]
pub trait Plugin: Send + Sync {
fn name(&self) -> &str;
async fn on_admit(&self, _event: &PluginEvent) {}
async fn on_reject(&self, _event: &PluginEvent) {}
async fn on_degrade(&self, _event: &PluginEvent) {}
}
pub struct PluginRegistry {
plugins: parking_lot::RwLock<BTreeMap<u64, Arc<dyn Plugin>>>,
names: parking_lot::RwLock<BTreeMap<String, u64>>,
next_seq: AtomicU64,
panicked_dispatches: AtomicU64,
}
impl Default for PluginRegistry {
fn default() -> Self {
Self::new()
}
}
impl PluginRegistry {
#[must_use]
pub fn new() -> Self {
Self {
plugins: parking_lot::RwLock::new(BTreeMap::new()),
names: parking_lot::RwLock::new(BTreeMap::new()),
next_seq: AtomicU64::new(0),
panicked_dispatches: AtomicU64::new(0),
}
}
pub fn register(&self, plugin: Arc<dyn Plugin>) -> Result<(), PluginError> {
let name = plugin.name().to_string();
let mut names = self.names.write();
if names.contains_key(&name) {
return Err(PluginError::AlreadyRegistered(name));
}
let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
self.plugins.write().insert(seq, plugin);
names.insert(name, seq);
Ok(())
}
pub fn unregister(&self, name: &str) -> bool {
let seq = self.names.write().remove(name);
match seq {
Some(seq) => {
self.plugins.write().remove(&seq);
true
}
None => false,
}
}
#[must_use]
pub fn get(&self, name: &str) -> Option<Arc<dyn Plugin>> {
let seq = self.names.read().get(name).copied()?;
self.plugins.read().get(&seq).cloned()
}
#[must_use]
pub fn list_names(&self) -> Vec<String> {
let mut by_seq: Vec<(u64, String)> = self
.names
.read()
.iter()
.map(|(name, seq)| (*seq, name.clone()))
.collect();
by_seq.sort_unstable_by_key(|(seq, _)| *seq);
by_seq.into_iter().map(|(_, name)| name).collect()
}
#[must_use]
pub fn len(&self) -> usize {
self.names.read().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.names.read().is_empty()
}
#[must_use]
pub fn panicked_dispatches(&self) -> u64 {
self.panicked_dispatches.load(Ordering::Relaxed)
}
pub async fn dispatch_admit(&self, event: &PluginEvent) {
let plugins: Vec<Arc<dyn Plugin>> = self.plugins.read().values().cloned().collect();
for plugin in plugins {
if AssertUnwindSafe(plugin.on_admit(event))
.catch_unwind()
.await
.is_err()
{
self.panicked_dispatches.fetch_add(1, Ordering::Relaxed);
}
}
}
pub async fn dispatch_reject(&self, event: &PluginEvent) {
let plugins: Vec<Arc<dyn Plugin>> = self.plugins.read().values().cloned().collect();
for plugin in plugins {
if AssertUnwindSafe(plugin.on_reject(event))
.catch_unwind()
.await
.is_err()
{
self.panicked_dispatches.fetch_add(1, Ordering::Relaxed);
}
}
}
pub async fn dispatch_degrade(&self, event: &PluginEvent) {
let plugins: Vec<Arc<dyn Plugin>> = self.plugins.read().values().cloned().collect();
for plugin in plugins {
if AssertUnwindSafe(plugin.on_degrade(event))
.catch_unwind()
.await
.is_err()
{
self.panicked_dispatches.fetch_add(1, Ordering::Relaxed);
}
}
}
}
pub struct LoggingPlugin {
pub target: String,
}
#[async_trait]
impl Plugin for LoggingPlugin {
fn name(&self) -> &str {
"logging"
}
async fn on_admit(&self, event: &PluginEvent) {
log::info!(
target: self.target.as_str(),
"admit: key={} rule={:?}",
event.key,
event.rule_id
);
}
async fn on_reject(&self, event: &PluginEvent) {
log::warn!(
target: self.target.as_str(),
"reject: key={} rule={:?} reason={}",
event.key,
event.rule_id,
event.reason
);
}
async fn on_degrade(&self, event: &PluginEvent) {
log::error!(
target: self.target.as_str(),
"degrade: key={} reason={}",
event.key,
event.reason
);
}
}
#[derive(Debug, Default)]
pub struct CounterPlugin {
admits: AtomicU64,
rejects: AtomicU64,
degrades: AtomicU64,
}
impl CounterPlugin {
#[must_use]
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
#[must_use]
pub fn admits(&self) -> u64 {
self.admits.load(Ordering::Relaxed)
}
#[must_use]
pub fn rejects(&self) -> u64 {
self.rejects.load(Ordering::Relaxed)
}
#[must_use]
pub fn degrades(&self) -> u64 {
self.degrades.load(Ordering::Relaxed)
}
}
#[async_trait]
impl Plugin for CounterPlugin {
fn name(&self) -> &str {
"counter"
}
async fn on_admit(&self, _event: &PluginEvent) {
self.admits.fetch_add(1, Ordering::Relaxed);
}
async fn on_reject(&self, _event: &PluginEvent) {
self.rejects.fetch_add(1, Ordering::Relaxed);
}
async fn on_degrade(&self, _event: &PluginEvent) {
self.degrades.fetch_add(1, Ordering::Relaxed);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn event(key: &str) -> PluginEvent {
PluginEvent::new(key, Some("rule-1".to_string()), "test reason")
}
#[tokio::test]
async fn register_dispatch_unregister_lifecycle() {
let registry = PluginRegistry::new();
let counter = CounterPlugin::new();
registry.register(counter.clone()).unwrap();
assert_eq!(registry.len(), 1);
assert_eq!(registry.list_names(), vec!["counter".to_string()]);
registry.dispatch_admit(&event("a")).await;
registry.dispatch_admit(&event("b")).await;
registry.dispatch_reject(&event("c")).await;
registry.dispatch_degrade(&event("d")).await;
assert_eq!(counter.admits(), 2);
assert_eq!(counter.rejects(), 1);
assert_eq!(counter.degrades(), 1);
assert!(registry.unregister("counter"));
assert!(
!registry.unregister("counter"),
"repeated unregister must return false"
);
registry.dispatch_admit(&event("e")).await;
assert_eq!(counter.admits(), 2, "no more dispatching after unregister");
assert!(registry.is_empty());
}
#[tokio::test]
async fn duplicate_registration_is_rejected_loudly() {
let registry = PluginRegistry::new();
registry
.register(Arc::new(LoggingPlugin {
target: "t1".to_string(),
}))
.unwrap();
let err = registry
.register(Arc::new(LoggingPlugin {
target: "t2".to_string(),
}))
.unwrap_err();
assert_eq!(
err,
PluginError::AlreadyRegistered("logging".to_string()),
"a same-name plugin registration must error explicitly instead of silently overwriting"
);
assert_eq!(registry.len(), 1);
}
#[tokio::test]
async fn dispatch_order_follows_registration_order() {
use std::sync::Mutex;
struct Recorder {
label: char,
log: Arc<Mutex<Vec<char>>>,
}
#[async_trait]
impl Plugin for Recorder {
fn name(&self) -> &str {
match self.label {
'c' => "recorder-c",
'a' => "recorder-a",
_ => "recorder-b",
}
}
async fn on_admit(&self, _event: &PluginEvent) {
self.log.lock().unwrap().push(self.label);
}
}
let log = Arc::new(Mutex::new(Vec::new()));
let registry = PluginRegistry::new();
for label in ['c', 'a', 'b'] {
registry
.register(Arc::new(Recorder {
label,
log: log.clone(),
}))
.unwrap();
}
registry.dispatch_admit(&event("k")).await;
assert_eq!(
*log.lock().unwrap(),
vec!['c', 'a', 'b'],
"the dispatch order must match the registration order"
);
}
#[test]
fn list_names_follows_registration_order_not_lexicographic() {
let registry = PluginRegistry::new();
for name in ["zeta", "alpha", "mid"] {
struct Named(String);
#[async_trait]
impl Plugin for Named {
fn name(&self) -> &str {
&self.0
}
}
registry
.register(Arc::new(Named(name.to_string())))
.unwrap();
}
assert_eq!(
registry.list_names(),
vec!["zeta".to_string(), "alpha".to_string(), "mid".to_string()],
"list_names must output in registration order (same basis as the dispatch order)"
);
assert!(registry.unregister("alpha"));
assert_eq!(
registry.list_names(),
vec!["zeta".to_string(), "mid".to_string()]
);
}
struct PanickyPlugin;
#[async_trait]
impl Plugin for PanickyPlugin {
fn name(&self) -> &str {
"panicky"
}
async fn on_reject(&self, _event: &PluginEvent) {
panic!("plugin bug");
}
}
#[tokio::test]
async fn plugin_panic_is_isolated_and_counted() {
let registry = PluginRegistry::new();
registry.register(Arc::new(PanickyPlugin)).unwrap();
let counter = CounterPlugin::new();
registry.register(counter.clone()).unwrap();
registry.dispatch_reject(&event("k")).await;
assert_eq!(
counter.rejects(),
1,
"subsequent plugins must not be interrupted by a panic"
);
assert_eq!(registry.panicked_dispatches(), 1);
}
#[tokio::test]
async fn dispatch_with_no_plugins_is_noop() {
let registry = PluginRegistry::new();
registry.dispatch_admit(&event("k")).await;
registry.dispatch_reject(&event("k")).await;
registry.dispatch_degrade(&event("k")).await;
assert_eq!(registry.panicked_dispatches(), 0);
}
#[tokio::test]
async fn logging_plugin_writes_log_records() {
let registry = PluginRegistry::new();
registry
.register(Arc::new(LoggingPlugin {
target: "plugin-test".to_string(),
}))
.unwrap();
registry.dispatch_admit(&event("k")).await;
registry.dispatch_reject(&event("k")).await;
registry.dispatch_degrade(&event("k")).await;
assert_eq!(registry.panicked_dispatches(), 0);
}
#[test]
fn get_returns_registered_plugin() {
let registry = PluginRegistry::new();
assert!(registry.get("counter").is_none());
registry.register(CounterPlugin::new()).unwrap();
let plugin = registry.get("counter").expect("registered");
assert_eq!(plugin.name(), "counter");
}
}