registry-io 0.7.0

High-performance event/callback registry for Rust. Sync-first with optional async. Lock-free reads, zero-allocation hot path, sub-50ns notify target. Designed as the foundation primitive for portfolio crates needing fast in-process notification.
Documentation

Highlights

  • Cross-platform — Linux, macOS, Windows.
  • Lock-free reads via ArcSwap snapshots. Many threads can notify concurrently with no coordination.
  • Zero allocation on the no-panic sync notify path.
  • Panic isolation — a panicking handler does not stop siblings nor propagate to the caller. Optional on_panic callback for observability. Works for both sync handlers and async futures.
  • Priority orderingregister_with_priority(i32, ...). Higher fires first; ties broken in registration order.
  • SyncRegistry<E> — generic over the event type. Handlers receive &E.
  • AsyncRegistry<E> (feature: async) — same lock-free storage, futures-returning handlers, concurrent or sequential dispatch.
  • RAII guardsregister_guard returns a HandlerGuard / AsyncHandlerGuard that unregisters on drop.
  • Send + Sync — share registries freely across threads.

Status

Active development. v0.7.0 is the hardening milestone: property-based invariant tests via proptest, an Arc::strong_count leak canary over 10 000 register/unregister cycles, a cargo-fuzz target scaffold, and a published threat model. The public API contains zero unsafe code.

v0.6.0 shipped the performance verification: every target in the Performance Contract is met with significant headroom — sync notify at ~10 ns / 1 handler / 1 thread, ~25 ns / 4 handlers / 16 threads contended, and dhat-verified zero heap allocations on the hot path. Async concurrent dispatch is ~180 ns / 1 handler. See docs/PERFORMANCE.md for the full measurements.

v0.5.0 added AsyncRegistry with concurrent + sequential dispatch, AsyncHandlerGuard, and panic isolation across .await, behind the async feature. The synchronous side (v0.4.0) — SyncRegistry, priority ordering, RAII guards, panic isolation — remains the default. See .dev/ROADMAP.md for the path to 1.0.

Public API is not yet frozen — minor releases may break it. Pin specific versions; expect changes pre-1.0.


When to use it

Use registry-io when you have:

  • Multiple components that need notification when something happens (config reload, file change, transaction commit, metric event, etc.).
  • Fast, in-process handlers measured in microseconds or less.
  • A need to register and unregister handlers dynamically.
  • Performance-critical paths where channel allocation would dominate.

Do not use registry-io when you have:

  • Cross-process or cross-network delivery needs — use NATS, Redis pub/sub, or similar message brokers.
  • Heavy handler workloads requiring backpressure — use tokio::sync::broadcast or channels.
  • Event sourcing or durability requirements — use a real event log.

 

Quick start

[dependencies]
registry-io = "0.7"
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use registry_io::SyncRegistry;

let registry: SyncRegistry<u32> = SyncRegistry::new();
let total = Arc::new(AtomicU32::new(0));

let sink = Arc::clone(&total);
let id = registry.register(move |value| {
    sink.fetch_add(*value, Ordering::Relaxed);
});

registry.notify(&5);
registry.notify(&7);
assert_eq!(total.load(Ordering::Relaxed), 12);

assert!(registry.unregister(id));

Priority ordering

use std::sync::{Arc, Mutex};
use registry_io::SyncRegistry;

let registry: SyncRegistry<()> = SyncRegistry::new();
let order = Arc::new(Mutex::new(Vec::<&'static str>::new()));

let o = Arc::clone(&order);
let _ = registry.register_with_priority(100, move |_| o.lock().unwrap().push("audit"));
let o = Arc::clone(&order);
let _ = registry.register(move |_| o.lock().unwrap().push("business"));
let o = Arc::clone(&order);
let _ = registry.register_with_priority(-50, move |_| o.lock().unwrap().push("cleanup"));

registry.notify(&());
assert_eq!(order.lock().unwrap().as_slice(), &["audit", "business", "cleanup"]);

RAII guards

use std::sync::Arc;
use registry_io::SyncRegistry;

let registry = Arc::new(SyncRegistry::<u32>::new());
{
    let _guard = registry.register_guard(|n| println!("scoped: {n}"));
    registry.notify(&1);
} // guard drops here -> handler is unregistered
assert!(registry.is_empty());

Panic isolation

use registry_io::SyncRegistry;

let registry: SyncRegistry<()> = SyncRegistry::new();
registry.on_panic(|info| {
    eprintln!(
        "handler {} panicked: {}",
        info.handler_id(),
        info.message().unwrap_or("<opaque>")
    );
});

let _ = registry.register(|_| panic!("oops"));
let _ = registry.register(|_| println!("still ran"));
registry.notify(&()); // returns cleanly; both effects observed

Async handlers (feature: async)

[dependencies]
registry-io = { version = "0.7", features = ["async"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use registry_io::r#async::AsyncRegistry;

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let registry: AsyncRegistry<u32> = AsyncRegistry::new();
    let total = Arc::new(AtomicU32::new(0));

    for _ in 0..4 {
        let sink = Arc::clone(&total);
        let _ = registry.register(move |value| {
            let sink = Arc::clone(&sink);
            let v = *value;
            async move {
                tokio::task::yield_now().await;
                sink.fetch_add(v, Ordering::Relaxed);
            }
        });
    }

    // Concurrent dispatch — all 4 futures run in parallel.
    registry.notify(&10).await;
    assert_eq!(total.load(Ordering::Relaxed), 40);

    // Sequential dispatch — awaits each handler in priority order.
    registry.notify_sequential(&10).await;
    assert_eq!(total.load(Ordering::Relaxed), 80);
}

Same lock-free read path as SyncRegistry. Panics in handler futures are caught via an internal CatchUnwind adapter and surfaced through on_panic, just like sync handlers.

See examples/ for runnable programs and docs/API.md for the full reference.


Design philosophy

  • Sync-first. The fast path is synchronous, runs on the calling thread, allocates nothing, and dispatches in nanoseconds.
  • Lock-free reads. Multiple threads can call notify() concurrently without contention.
  • Zero allocation on the hot path. Notify walks the handler list and dispatches without any heap allocation in the no-panic case.
  • Focused scope. This is a local, in-process notification primitive. Not a message bus, not a distributed event system, not a pub/sub broker.

Documentation


Standards

  • REPS (Rust Efficiency & Performance Standards) governs every decision. See REPS.md.
  • MSRV: Rust 1.85.
  • Edition: 2024.
  • Cross-platform: Linux, macOS, Windows.

License

Dual-licensed under either of:

at your option.

Contribution

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.