This document describes the proposed Phase 1 architecture for the extension system in the OTAP dataflow engine, building on the extension system proposal which establishes the vision, goals, and phased rollout plan.
A working proof of concept is available on the PoC branch.
The proposal defines what the extension system should do and why. This document describes how each requirement is addressed in the Phase 1 implementation:
| Proposal Requirement | Phase 1 Approach |
|---|---|
| Capability-based access | #[capability] proc macro generates typed traits; consumers resolve via require_local() / require_shared() |
| Multiple implementations of same capability | CapabilityRegistry keyed by (extension_name, TypeId) -- different extensions can provide the same capability |
| Multiple configured instances | extensions: section in YAML, each with a unique name; nodes bind by name in capabilities: |
| Existing config model integration | Extensions are siblings to nodes in the pipeline config hierarchy |
| Preserve performance model (thread-per-core) | Local extensions use Rc (no locks); shared extensions use Clone + Send with Arc-wrapped state |
| Background tasks | Active extensions get their own event loop via Extension::start() |
| Explicit capability binding | Nodes declare capabilities: { name: extension_instance } -- no implicit discovery |
| No hot-path registry lookup | Capabilities resolved once at factory time; nodes hold typed handles for their lifetime |
| Future hierarchical scopes | CapabilityRegistry and resolve_bindings() are scope-agnostic by design |
Beyond the proposal's requirements, the design rests on four additional principles:
- Pipeline-scoped extensions are per-core. Both local
and shared pipeline-scoped extensions are instantiated
per pipeline instance (i.e., per core). The
local/shared distinction at pipeline scope is about type
constraints (
!SendvsSend + Clone), not about cross-core sharing. This follows a consistent principle: an extension's sharing boundary is determined by the scope it is declared in, not by its execution model. Pipeline is the only scope in Phase 1; broader scopes (group, engine) and narrower scopes (node-set, node) are future work and will each define their own sharing boundary. The execution model (local vs shared) determines only the type constraints imposed on the implementation. - Active/Passive lifecycle distinction. Not every extension needs a background task. Extensions that only provide capabilities are marked Passive -- no task is spawned, no control channel is allocated, no shutdown messages are sent, with zero runtime overhead. Extensions that drive their own event loop are marked Active and receive a task, a control channel, and shutdown orchestration.
- Instance policy (Cloned vs Constructed). A
capability consumer receives either a clone of a stored
prototype (
.cloned()) or a newly-constructed instance from a user-supplied closure (.constructed()). The policy is chosen by the extension author at build time and is invisible to consumers --require_shared()/require_local()return the same trait-object type regardless..constructed()is Passive-only; the combinationActive + Constructedis unrepresentable in the typestate builder (an Active extension has a single engine-driven event loop that doesn't compose with per-consumer construction). - Shared-to-local transparent fallback. A shared-only
extension can serve local consumers via a
wrap_shared_as_localadapter (generated by the#[capability]proc macro). Most extensions only need a shared implementation; local consumers are served automatically. Extension authors who want the lock-free performance of a local-only implementation (e.g.,Rc<RefCell>instead ofArc<RwLock>) can opt in by providing a dedicated local variant.
Local capabilities return Rc<dyn local::Trait> -- all
local consumers share the same instance via reference
counting. No cloning, no locks.
Shared capabilities return Box<dyn shared::Trait> --
each consumer gets an independent clone. For shared
extensions to share mutable state across clones (e.g.,
token senders, connection pools), fields should be wrapped
in Arc, similar to how tokio, axum, and reqwest
handle shared state:
#[derive(Clone)]
struct MyExtension {
// Shared mutable state -- Arc ensures clones
// see the same data
token_sender: Arc<watch::Sender<Option<BearerToken>>>,
credential: Arc<dyn TokenCredential>,
// Plain data -- cloned independently per consumer
scope: String,
}Extensions are standalone pipeline components that provide
shared, cross-cutting capabilities -- such as
authentication, storage etc. -- to
data-path nodes (receivers, processors, exporters). They
are configured as siblings to nodes, not as nodes
themselves, and they never touch pipeline data directly.
+----------------------------------------------------------+
| Pipeline Engine |
| |
| +-------------------+ +-------------------+ |
| | Extension A | | Extension B | ... |
| | Active(auth) | | Passive(kv store) | |
| | local + shared | | shared only | |
| | lifecycle | | no task spawned | |
| +---------+---------+ +---------+---------+ |
| | #[capability] proc macro |
| | + extension_capabilities!() macro |
| v |
| +----------------------------+ |
| | CapabilityRegistry | (built once per |
| | local_handles HashMap | pipeline) |
| | shared_handles HashMap | |
| +----+-----------------+-----+ |
| | resolve_bindings| |
| v v |
| +-----------+ +-----------+ |
| | Receiver | | Exporter | |
| | require | | require | |
| | _local() | | _shared() | |
| | -> Rc<T> | | -> Box<T> | |
| +-----------+ +-----------+ |
| |
| Local consumers get Rc<dyn local::Trait> |
| Shared consumers get Box<dyn shared::Trait> (Send) |
+----------------------------------------------------------+
-
Extensions start first, shut down last. Active extensions are spawned before data-path nodes. At shutdown, extensions terminate only after all data-path nodes have drained. Passive extensions (no lifecycle) skip spawning entirely.
-
PData-free. Extensions are completely decoupled from the pipeline data type. They use
ExtensionControlMsgthrough a dedicated control channel. -
Active vs Passive lifecycle. Extensions choose their lifecycle at build time via a typestate builder stage:
.active()or.passive(). Active extensions get a task and control channel. Passive extensions only provide capabilities -- no task is spawned, no control channel is allocated, no messages are sent. The.active()stage only accepts types implementingExtension; the.passive()stage does not require that trait. -
Background lifecycle (zero capabilities). A third lifecycle,
.background(), sits alongside.active()/.passive()for engine-driven services that expose no capability -- periodic reporters, schedulers, health monitors, global rate-limit coordinators. The builder shape is.background().shared(impl_)or.background().local(Rc::new(impl_))followed by.build(); exactly one registration is required and a second is unrepresentable in the typestate. The choice of.shared(...)vs.local(...)only governs how the engine hosts the instance (Send + Clonevs!Send, per-pipeline). Background extensions never appear as the right-hand side of a capability binding; their factory'scapabilitiesfield isOption<_>::None, and thatNoneis the engine's runtime signal "this is a Background extension" -- capability registration is skipped entirely. For lifecycle dispatch (event loop, control channel, shutdown sequencing) Background is handled exactly like Active. The shape constraints are compile-time enforced by the typestate builder:- Active and Passive must register >=1 capability.
- Background must register 0 capabilities (no
extension_capabilities!invocation). - Background must register exactly one of
.shared(...)/.local(...)-- never both.
-
Instance policy (Passive only). After
.passive(), the extension author picks an instance policy. The choice is provider-side only -- capability consumers callrequire_shared()/require_local()and cannot observe which policy was used..cloned()-- each consumer receives a clone of the value handed to the builder. The semantics differ by execution model:- For
.shared(value: E)(E: Clone + Send), each consumer getsvalue.clone()-- an independent copy of the underlying object. - For
.local(rc: Rc<E>), each consumer getsRc::clone(&rc)-- a new handle to the same underlying object. Local consumers in the same pipeline instance therefore share one extension instance.
- For
.constructed()-- each consumer receives a newly-constructed instance from a user-suppliedFn() -> E + Cloneclosure.
Active + Constructedis unrepresentable:ActiveStageexposes no.constructed()method, so the invalid combination is a compile-time error. -
Local/Shared split. Each lifecycle/policy stage lets the user register at most one
.shared(...)variant and one.local(...)variant. A single extension can provide one or both:- Shared-only (with local fallback):
.active().shared(ext).build()-- the shared type serves both local and shared consumers viawrap_shared_as_localfallback. This is the most common pattern. - Local-only:
.active().local(Rc::new(ext)).build()-- only local consumers can use this extension. Shared consumers (require_shared()) get a config error. Use when the extension is inherently!Send. - Dual-type:
.active().local(Rc::new(l)).shared(s).build()-- separate types with independent lifecycles. - Passive cloned:
.passive().cloned().shared(ext).build()-- no lifecycle; consumers clone a stored prototype. - Passive constructed:
.passive().constructed().shared(|| MyExt::new(cfg.clone())).build()-- no lifecycle; each consumer invokes the stored constructor closure.
- Shared-only (with local fallback):
-
Type-safe capability resolution. Consumers call
capabilities.require_local::<BearerTokenProvider>()(returnsRc<dyn local::BearerTokenProvider>) orcapabilities.require_shared::<KeyValueStore>()(returnsBox<dyn shared::KeyValueStore>, which isSend). The zero-sized registration struct carries associated types (LocalandShared) that map to the correct trait object variants. Sealing viaExtensionCapabilityensures only engine-defined capabilities are accepted at compile time. Each of the four accessors (require_*/optional_*) is intended to be called exactly once per capability per node at node construction; a second call returnsError::CapabilityAlreadyConsumed. Local fallback from shared extensions is materialized lazily at that call: onrequire_local(), the registered shared factory runs and its result is routed through the capability'swrap_shared_as_localadapter to produceRc<dyn C::Local>. -
#[capability]proc macro. Each capability is defined via a single#[capability]attribute on a trait definition. The macro generates:local::andshared::trait variants, awrap_shared_as_localadapter fn for transparent fallback, sealed trait impls, a zero-sized registration struct, aKNOWN_CAPABILITIESlink-time entry (viadistributed_slice), and typedshared_entry::<E>/local_entry::<E>caster functions that bridge an extension'sSharedInstanceFactory/LocalInstanceFactoryinto a registry entry. Consumers use trait objects directly. Shared data types (e.g.,BearerToken,Secret) are hand-written alongside the macro invocation.
engine/src/
lib.rs -> ExtensionFactory, engine build logic
extension/
mod.rs -> module root, ExtensionBundle,
ExtensionLifecycle, ExtensionWrapper
builder.rs -> Typestate builder: ActiveStage,
PassiveStage, PassiveClonedStage,
PassiveConstructedStage
wrapper.rs -> ExtensionWrapper variants,
ControlChannel, EffectHandler
tests.rs -> extension-level tests
capability/
mod.rs -> ExtensionCapability sealed trait,
KnownCapability, extension_capabilities!
declarative macro, KNOWN_CAPABILITIES
factory.rs -> SharedInstanceFactory /
LocalInstanceFactory (cloneable
type-erased produce closures encoding
instance policy)
tests.rs -> extension_capabilities! macro tests
registry/
mod.rs -> public re-exports
entry.rs -> SharedCapabilityEntry, LocalCapabilityEntry,
ResolvedSharedEntry, ResolvedLocalEntry,
cloneable produce closures
storage.rs -> CapabilityRegistry HashMap store
capabilities.rs -> Per-node Capabilities handle
(require_*/optional_*)
resolve.rs -> resolve_bindings: validate + produce
per-node Capabilities
tracker.rs -> ConsumedTracker for unused-extension
accounting
tests.rs -> end-to-end registry tests
local/
extension.rs -> Extension trait (!Send, Rc<Self>)
capability.rs -> re-exports (populated per-capability)
exporter.rs, receiver.rs, processor.rs (unchanged)
shared/
extension.rs -> Extension trait (Send, Box<Self>)
capability.rs -> re-exports (populated per-capability)
exporter.rs, receiver.rs, processor.rs (unchanged)
The module dependency is one-way: extension/ depends on
capability/, not the reverse. The instance factories live in
capability/factory.rs because they're consumed by the capability
registry's registration fn pointers; extension/builder.rs and
extension/wrapper.rs import them from there.
Concrete capabilities (e.g. bearer_token_provider,
key_value_store) land alongside their first consumer in
subsequent PRs; the capability/ tree above holds only the
registry infrastructure and the sealed
ExtensionCapability trait.