Port the call + channels architecture documentation from the alknet mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045. Renumbering map (alknet -> alkcall): Core: 001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012 Call: 005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030 Shared: 003,009,013 -> 031-033 Channels: 071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045 3 superseded/reversed ADRs kept for historical trail: - ADR-013 (irpc foundation, superseded by ADR-014) - ADR-023 (peer-scoped filtering, superseded by ADR-024) - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only) Ported docs (11 spec files + README + open-questions): - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md - README.md (index with doc table, ADR table grouped by category, key principles) - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap) Cross-reference rewriting: - All ADR-NNN references rewritten single-pass (no chaining bug) - Markdown link paths fixed - Title lines aligned with filenames - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note The open-questions.md includes OQ-22 (new): the call protocol pub/sub gap — subscribe exists but pub does not, needed for channels channel/resources/subscribe fan-out. This is the next ADR to write (alkcall ADR-046).
8.9 KiB
ADR-029: Aggregated Peer-Environment Wiring for Hub Deployments
Status
Proposed
Context
Dispatcher::compose_root_env (protocol/dispatch.rs:134-154) constructs a
fresh PeerCompositeEnv per call and attaches only the current call's
own connection as a peer overlay. It does not aggregate the hub's other live
worker connections into that call's environment.
Consequence: on a hub with N connected workers, a handler composing
env.invoke_peer(&PeerRef::Specific("dev1"), "docker", "container/exec", input, &ctx, policy) from a call that arrived on an HTTP connection (or on a
different worker's connection) will not find dev1's overlay — the routing
falls through to the curated base and returns NOT_FOUND.
The PeerCompositeEnv type and the invoke_peer routing logic are built for
multi-peer aggregation (attach_peer/detach_peer with insertion-order
preservation, PeerRef::Specific/Any routing — registry/env.rs:155-301).
The per-call compose_root_env does not use that capability. The ADR-024
model is committed; the implementation is incomplete for the head→N-workers
case.
This is the single highest-impact gap a first hub consumer (alkapi) surfaces. A hub is defined by composing ops across its connected workers. Without an aggregated env shared across all calls, the hub pattern does not work: a call arriving on one transport cannot reach a worker connected on another.
The alkapi project identified this as OQ-08 and committed to the aggregation decision in their ADR-006. The decision to aggregate is made; the question is where the wiring lives — alknet (reusable by any hub) or a hub-side wrapper (alkapi-only). This ADR resolves that question: the wiring lives in alknet.
Decision
1. Dispatcher gains a with_aggregated_env builder method
A new optional field on Dispatcher holds a shared aggregated
PeerCompositeEnv:
pub struct Dispatcher {
pub registry: Arc<OperationRegistry>,
pub identity_provider: Arc<dyn IdentityProvider>,
pub session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
pub ownership_provider: Option<Arc<dyn OwnershipProvider>>,
pub aggregated_env: Option<Arc<std::sync::RwLock<PeerCompositeEnv>>>,
pub default_timeout: Duration,
}
impl Dispatcher {
pub fn with_aggregated_env(
mut self,
env: Arc<std::sync::RwLock<PeerCompositeEnv>>,
) -> Self {
self.aggregated_env = Some(env);
self
}
}
The builder method mirrors with_session_source and with_ownership_provider
— an optional hook the assembly layer wires at construction time. A deployment
that does not set an aggregated env gets today's compose_root_env behavior
unchanged.
2. CallAdapter gains a matching with_aggregated_env builder method
CallAdapter delegates to Dispatcher:
impl CallAdapter {
pub fn with_aggregated_env(
mut self,
env: Arc<std::sync::RwLock<PeerCompositeEnv>>,
) -> Self {
self.dispatcher = self.dispatcher.with_aggregated_env(env);
self
}
}
3. compose_root_env reads the aggregated env when set
When aggregated_env is Some, compose_root_env reads the shared env,
attaches the current connection's overlay as an override for the current call
only, and returns the result. When None, the existing per-call behavior is
preserved:
pub fn compose_root_env(
&self,
connection: &CallConnection,
context: &OperationContext,
) -> Arc<dyn OperationEnv + Send + Sync> {
let base: Arc<dyn OperationEnv + Send + Sync> =
Arc::new(LocalOperationEnv::new(Arc::clone(&self.registry)));
let session = self
.session_source
.as_ref()
.and_then(|s| s.overlay_for(context));
if let Some(aggregated) = &self.aggregated_env {
// Acquire read lock on the shared aggregated env, clone it (cheap —
// all fields are Arc), and release the lock. The clone is the
// per-call snapshot; the lock is not held for the call duration.
let mut env = aggregated
.read()
.expect("aggregated env lock poisoned")
.clone();
// Attach the current connection's overlay as an override for this
// call only. The current connection's overlay is the authoritative
// view of *that* peer; the aggregated env is the authoritative view
// of *all other* peers. This avoids a race where the aggregated env
// has not yet picked up a new op the current peer just registered.
if let Some(peer_id) = connection.identity().map(|identity| identity.id.clone()) {
env.attach_peer(peer_id, connection.overlay_env());
}
Arc::new(env)
} else {
let mut env = PeerCompositeEnv::new(base);
if let Some(session) = session {
env = env.with_session(session);
}
if let Some(peer_id) = connection.identity().map(|identity| identity.id.clone()) {
env.attach_peer(peer_id, connection.overlay_env());
}
Arc::new(env)
}
}
The clone of the aggregated env is cheap: PeerCompositeEnv's fields are all
Arc (the HashMap values are Arc<dyn OperationEnv>, the Vec is
Vec<PeerId> which is a String clone). The RwLock::read() is held only
for the clone, not for the duration of the call.
4. The hub owns the aggregated env lifecycle
The hub (assembly layer) constructs the aggregated env once at startup:
let base: Arc<dyn OperationEnv + Send + Sync> =
Arc::new(LocalOperationEnv::new(Arc::clone(®istry)));
let aggregated = Arc::new(RwLock::new(PeerCompositeEnv::new(base)));
let adapter = CallAdapter::new(registry, identity_provider)
.with_aggregated_env(Arc::clone(&aggregated));
The hub calls attach_peer(peer_id, overlay) on the aggregated env on every
worker connection-establish (after from_call populates the overlay) and
detach_peer(&peer_id) on every disconnect. The write lock is held only for
the HashMap insert/remove — connection-rate, not call-rate.
5. PeerCompositeEnv gains Clone
PeerCompositeEnv is made Clone (all fields are Arc or Clone already).
This is a one-line derive addition.
Consequences
Positive:
- The hub pattern works. A call arriving on any transport can reach any
connected worker's ops via
PeerRef::SpecificorPeerRef::Any. - The existing single-connection behavior is preserved. A deployment that does
not set an aggregated env gets today's
compose_root_envunchanged. - The hook is additive — a new optional field, a new builder method, a branch
in
compose_root_env. No existing code path changes. - The capability is reusable by any future hub, not just alkapi. The alkapi project's ADR-006 fallback (hub-side wrapper) is no longer needed.
Negative:
- A
RwLock<PeerCompositeEnv>on the read hot path of every dispatch. The lock is held only for a clone (allArcfields — cheap). AnArcSwapcopy-on-write variant could avoid the lock on reads entirely, at the cost of a clone onattach_peer/detach_peer(infrequent). TheRwLockis the simpler starting point;ArcSwapis an additive optimization. PeerCompositeEnvgainsClone. The derive is mechanical; all fields are alreadyClone.- The hub must manage the aggregated env lifecycle (
attach_peer/detach_peeron connection events). This is assembly-layer code, not alknet-call code. The hooks exist; the hub wires them.
Assumptions
PeerCompositeEnvclone is cheap. All fields areArcorCloneof small types (String,Vec<String>). The clone does not copy the operation registries or the connection overlays — it copiesArcpointers.- The current connection's overlay is authoritative for that peer. A call arriving on worker-a uses worker-a's live overlay as the view of worker-a (not the aggregated env's possibly-stale snapshot), and the aggregated env for all other peers. This avoids a race where the aggregated env has not yet picked up a new op worker-a just registered.
- The
RwLockis not a contention point. Reads (clones) are call-rate but the lock is held only for the clone duration (microseconds). Writes (attach_peer/detach_peer) are connection-rate (seconds to minutes). If profiling shows contention,ArcSwapis the additive optimization.
References
- ADR-024: Peer-Graph Routing Model (the model this wiring completes)
- ADR-025: PeerEntry and Identity.id Decoupling (the
PeerIdsource) - ADR-030: PeerCompositeEnv::peer_operations Override (sibling hub-wiring decision)
- ADR-028: from_call Is a Manual Free Function (sibling hub-wiring decision)
- alkapi ADR-006: Aggregated Peer Environment (the downstream commitment)
- alkapi OQ-08: alknet aggregated peer-env wiring (the blocking question)
crates/alknet-call/src/protocol/dispatch.rs:134-154— currentcompose_root_envcrates/alknet-call/src/registry/env.rs:155-301—PeerCompositeEnv