Compose primitives via SidecarBox
SidecarBox<T> is the RAII wrapper around an AdaptiveInstance.
It boxes the primitive, registers it with the global sidecar at
construction, and unregisters on drop. For the vast majority of
use cases this is the only registration API you should touch.
This guide covers the patterns where it works, the patterns where it does not, and the escape hatches when it does not.
The default pattern
The CXC primitives’ constructors are create(path, capacity) /
open(path, capacity) returning Result<Self>. Wrap them in
SidecarBox::new:
use subetha_cxc::SharedHashMap;
use subetha_sidecar::SidecarBox;
let m = SidecarBox::new(
SharedHashMap::<u32, u64>::create("/tmp/sessions.bin", 1024).unwrap()
);
m.insert(42, 4242).unwrap();SidecarBox::new does three things:
- Boxes the value so the heap address is stable.
- Captures
NonNullpointers to the value’sHandshakeHeader,ObservationRing, and trait object. - Calls the value’s
make_policy()to get the policy and registers everything withglobal().register_raw(...), storing the returnedInstanceIdinside the wrapper.
Deref<Target = T> makes the wrapper transparent: m.insert(42, 4242) resolves to SharedHashMap::insert(42, 4242) via deref.
Trait methods on AdaptiveInstance (header(), ring(),
make_policy(), apply_migration()) are accessible the same way
as long as AdaptiveInstance is in scope.
Drop order is load-bearing
pub struct SidecarBox<T: AdaptiveInstance> {
// ORDER MATTERS: handle drops before inner.
handle: SidecarHandle,
inner: Box<T>,
}Rust drops struct fields in declaration order. handle drops
first; its Drop calls sidecar.unregister(id), which blocks
until any in-flight scan iteration on the relevant NUMA node
finishes. Then inner: Box<T> drops, freeing the header / ring
memory. Reversing the field order lets the box drop while a scan
thread holds pointers into the header. That is a use-after-free.
This invariant is what makes the wrapper safe. So long as you use
SidecarBox::new(prim) and let it drop normally, the order is
correct by construction.
Sharing across threads: Arc<SidecarBox<T>>
SidecarBox<T> is Send + Sync if T is. For multi-threaded
access from owning code wrap in Arc:
use std::sync::Arc;
use subetha_cxc::SharedRWLock;
use subetha_sidecar::SidecarBox;
let lock = Arc::new(SidecarBox::new(
SharedRWLock::<u64>::create("/tmp/cfg.bin", 0).unwrap()
));
let lock_clone = lock.clone();
std::thread::spawn(move || {
lock_clone.with_write(|v| *v += 1);
});
lock.with_read(|v| println!("v = {}", v));The Arc<SidecarBox<T>> pattern is correct for the common case.
The SidecarBox’s drop runs when the last Arc drops, the
sidecar gets unregistered, then the primitive memory is freed.
The deref chain Arc -> SidecarBox -> T is unambiguous.
Important
The Arc goes around the SidecarBox, not the other way
around. Putting the Arc inside (as in
SidecarBox::new(Arc::new(prim))) registers the Arc’s heap
address with the sidecar. That is not what you want. The header
and ring live inside the primitive, not on the Arc.
When SidecarBox does not fit: Arc-from-construction
SidecarBox<T> owns the inner T via Box<T>. A primitive
that ships an Arc<Self>-returning new (because its internal
state requires shared ownership from construction) does not
compose with SidecarBox::new(value) cleanly.
For that case, use Sidecar::register_raw directly:
use std::sync::Arc;
use std::ptr::NonNull;
use subetha_sidecar::{AdaptiveInstance, global};
let prim: Arc<MyPrimitive> = MyPrimitive::new_arc();
let header = NonNull::from(prim.header());
let ring = NonNull::from(prim.ring());
let instance_ref: &dyn AdaptiveInstance = &*prim;
let instance_ptr: *const dyn AdaptiveInstance = instance_ref;
let instance = unsafe {
NonNull::new_unchecked(instance_ptr as *mut dyn AdaptiveInstance)
};
let policy = prim.make_policy();
let id = unsafe {
global().register_raw(header, ring, Some(instance), policy)
};
// ... use `prim` ...
// CRITICAL: unregister BEFORE the last Arc drops.
global().unregister(id);
drop(prim);The lifetime invariant moves to you: unregister(id) must happen
before the underlying memory is freed. If the last Arc drops
first, the sidecar still holds a NonNull<HandshakeHeader>
pointing at freed memory until the next scan does its read lock.
unregister blocks until any in-flight scan finishes, so calling
it before the last Arc::drop is sufficient: when unregister
returns, no scan thread holds a pointer into the instance, and
the Arc::drop that follows is safe.
When SidecarBox does not fit: stats-only registration
Some primitives want sidecar observation (so they show up in
InstanceStats and the policy decision flow) but do not want the
sidecar to call apply_migration on them. Pass None for the
instance pointer and Box::new(NoMigrationPolicy):
use subetha_sidecar::{NoMigrationPolicy, global};
let id = unsafe {
global().register_raw(
NonNull::from(prim.header()),
NonNull::from(prim.ring()),
None, // no instance pointer = no apply_migration calls
Box::new(NoMigrationPolicy),
)
};The scan thread still drains the observation ring and accumulates
InstanceStats. The policy still gets called; it just returns
None from decide(). The instance never gets migrated. This is
the right shape for primitives whose strategy is fixed at
construction (every primitive in the subetha-cxc MMF family
defaults to this) or for telemetry-only observers.
Common pitfalls
Constructing a SidecarBox-wrapped primitive inside a tight
loop. The sidecar has a hard cap of 10,000
simultaneously-registered instances (DEFAULT_MAX_INSTANCES). A
criterion benchmark that calls SidecarBox::new(...) inside
b.iter(|| { ... }) registers a new instance per iteration,
accumulates registrations across the warm-up and measurement
phases, and panics with a diagnostic when it crosses the cap.
The fix is to lift the construction outside the b.iter closure
and reuse the instance:
// WRONG: registers a new instance per iteration.
b.iter(|| {
let m = SidecarBox::new(
SharedHashMap::<u32, u64>::create("/tmp/m.bin", 1024).unwrap()
);
m.insert(black_box(42), black_box(4242)).unwrap();
});
// RIGHT: register once, reuse inside b.iter.
let m = SidecarBox::new(
SharedHashMap::<u32, u64>::create("/tmp/m.bin", 1024).unwrap()
);
b.iter(|| {
m.insert(black_box(42), black_box(4242)).unwrap();
});The diagnostic message names the cap, hints at the b.iter() /
loop misuse, and suggests Sidecar::set_max_instances(...) as the
escape hatch for intentional heavy-registration workloads.
Wrapping a SidecarBox<T> in another SidecarBox. The type
system blocks this directly. SidecarBox<T> does not implement
AdaptiveInstance, so SidecarBox::new(SidecarBox::new(prim))
fails to compile.
Passing a borrowed reference where ownership is expected.
SidecarBox::new takes the value by move, not by reference. A
primitive that needs to be aliased across threads goes inside an
Arc (Arc::new(SidecarBox::new(prim))), not behind a &.
See also
SidecarBox<T>- the RAII wrapper’s API surface.AdaptiveInstance- the traitTmust satisfy.- Sidecar registry
-
what
register_rawandunregisterdo internally. - Tune the sidecar - raising the instance cap and other knobs.