docs(arch): clean unwrap, remove stale ownership, protocol-agnostic hub core
- Replace .unwrap() with .expect() in spec pseudocode; remove .unwrap() from ADR-067 prose. Specs describe intent, not implementation details. - Remove 'ownership reaping' from hub's 'does NOT do' list. Ownership is an alknet-docker concern, not a hub concern. The other agent's hedging leaked into the spec; removed. - Restructure hub README: separate core peer lifecycle (protocol-agnostic) from call-protocol strategy (first strategy). Add extension points section for future tty/blobs/custom strategies. A QUIC connection can multiplex multiple protocols over separate streams; the hub's core manages the connection, each strategy manages its own protocol-specific state. The call-protocol strategy handles the majority of real-world use cases; the architecture keeps the door open for others.
This commit is contained in:
1 parent
87b2c2a5ef
commit
11f531131c
2 files changed
+97
-37
No files matched your search
@@ -5,10 +5,11 @@ last_updated: 2026-07-09
|
||||
|
||||
# alknet-hub
|
||||
|
||||
The hub pattern as a reusable crate: aggregated peer environment, connection
|
||||
lifecycle hooks, worker supervision, and service discovery across connected
|
||||
peers. Consumes `alknet-call` types; provides the assembly-layer wiring every
|
||||
hub needs.
|
||||
The hub pattern as a reusable crate: peer lifecycle management, connection
|
||||
supervision, and service discovery across connected peers. The call-protocol
|
||||
strategy (operation import via `from_call`, routing via `PeerCompositeEnv`)
|
||||
is the first and primary strategy; the core peer lifecycle is
|
||||
protocol-agnostic.
|
||||
|
||||
## What
|
||||
|
||||
@@ -18,19 +19,26 @@ not introduce new types — it wires the existing `PeerCompositeEnv`,
|
||||
`CallClient`, `CallAdapter`, `from_call`, and `Dispatcher` types into a
|
||||
coherent hub runtime.
|
||||
|
||||
A hub is a call-protocol node that:
|
||||
The hub has two layers:
|
||||
|
||||
1. **Aggregates multiple worker connections** into a single
|
||||
`PeerCompositeEnv` shared across all calls.
|
||||
2. **Discovers worker operations** via `from_call` on connection establishment.
|
||||
3. **Supervises worker connections** — reconnects on drop with backoff,
|
||||
re-discovers ops on reconnect, detaches on disconnect.
|
||||
4. **Exposes service discovery** — `services/list-peers` returns each
|
||||
connected worker's operation list.
|
||||
1. **Core peer lifecycle** — protocol-agnostic. The hub knows which peers are
|
||||
connected, manages their connection lifecycle (dial, accept, disconnect,
|
||||
reconnect with backoff), and resolves their identities. This layer does not
|
||||
know what a peer *does* — it only knows that a peer is connected and has an
|
||||
identity.
|
||||
|
||||
The crate provides these four capabilities. A downstream consumer (alkapi,
|
||||
any future hub) constructs a `Hub`, registers its curated operations, and
|
||||
starts accepting/dialing workers. The hub handles the rest.
|
||||
2. **Call-protocol strategy** — the first strategy. When a call-protocol peer
|
||||
connects, the hub discovers its operations via `from_call`, registers them
|
||||
in the aggregated `PeerCompositeEnv`, and routes operation calls to the
|
||||
right peer via `PeerRef`. This is the strategy that handles the majority of
|
||||
real-world hub use cases (docker workers, agent tool dispatch, service
|
||||
composition).
|
||||
|
||||
Future strategies (tty, blobs, custom protocols) plug into the same core peer
|
||||
lifecycle. A QUIC connection can multiplex multiple protocols over separate
|
||||
streams — a single peer connection could carry both call-protocol operations
|
||||
and tty sessions. The hub's core lifecycle manages the connection; each
|
||||
strategy manages its own protocol-specific state on top.
|
||||
|
||||
## Why
|
||||
|
||||
@@ -55,10 +63,11 @@ independently, `alknet-hub` provides it once, as a reusable crate.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Hub struct
|
||||
### Core: Hub struct and peer lifecycle
|
||||
|
||||
The `Hub` is the central type. It owns the aggregated `PeerCompositeEnv`, the
|
||||
`OperationRegistry`, and the `Dispatcher`:
|
||||
The `Hub` is the central type. It owns the aggregated `PeerCompositeEnv`
|
||||
(used by the call-protocol strategy), the `OperationRegistry`, and the
|
||||
`Dispatcher`:
|
||||
|
||||
```rust
|
||||
pub struct Hub {
|
||||
@@ -103,9 +112,10 @@ impl Hub {
|
||||
The `Hub` exposes builder methods for optional hooks (`with_session_source`,
|
||||
`with_ownership_provider`, `with_timeout`) that delegate to the `Dispatcher`.
|
||||
|
||||
### Worker connection lifecycle
|
||||
### Call-protocol strategy
|
||||
|
||||
The hub provides two paths for worker connections:
|
||||
The call-protocol strategy is what the hub *does* when a call-protocol peer
|
||||
connects: discover its operations, register them, and make them routable.
|
||||
|
||||
#### Inbound workers (workers dial the hub)
|
||||
|
||||
@@ -114,9 +124,10 @@ The hub's `CallAdapter` accepts inbound connections. The hub provides an
|
||||
|
||||
```rust
|
||||
impl Hub {
|
||||
/// Called by the assembly layer when a worker connects inbound (via
|
||||
/// CallAdapter::handle). Runs from_call, registers the bundles in the
|
||||
/// connection's overlay, and attaches the peer to the aggregated env.
|
||||
/// Called by the assembly layer when a call-protocol worker connects
|
||||
/// inbound (via CallAdapter::handle). Runs from_call, registers the
|
||||
/// bundles in the connection's overlay, and attaches the peer to the
|
||||
/// aggregated env.
|
||||
pub async fn on_worker_connected(
|
||||
&self,
|
||||
connection: &CallConnection,
|
||||
@@ -129,9 +140,12 @@ impl Hub {
|
||||
let bundles = from_call(connection, config).await?;
|
||||
connection.register_imported_all(bundles);
|
||||
|
||||
// Acquire write lock on the aggregated env and attach this peer's
|
||||
// connection overlay. The lock is held only for the HashMap insert
|
||||
// (connection-rate, not call-rate).
|
||||
self.aggregated_env
|
||||
.write()
|
||||
.unwrap()
|
||||
.expect("aggregated env lock poisoned")
|
||||
.attach_peer(peer_id.clone(), connection.overlay_env());
|
||||
|
||||
Ok(peer_id)
|
||||
@@ -139,11 +153,22 @@ impl Hub {
|
||||
|
||||
/// Called by the assembly layer when a worker disconnects (run_loop exits).
|
||||
pub fn on_worker_disconnected(&self, peer_id: &PeerId) {
|
||||
self.aggregated_env.write().unwrap().detach_peer(peer_id);
|
||||
self.aggregated_env
|
||||
.write()
|
||||
.expect("aggregated env lock poisoned")
|
||||
.detach_peer(peer_id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note on `expect`**: The `RwLock` is held only for the duration of the
|
||||
> `HashMap` insert/remove. A poisoned lock indicates a panic in another thread
|
||||
> while holding the write lock — an unrecoverable state. The `expect` message
|
||||
> documents this invariant. The implementation may use a different concurrency
|
||||
> primitive (e.g. `ArcSwap`) that avoids poisoning entirely; the spec
|
||||
> describes the intent (acquire exclusive access, insert/remove, release),
|
||||
> not the mechanism.
|
||||
|
||||
#### Outbound workers (hub dials workers)
|
||||
|
||||
The hub provides a `dial_worker` method that combines `CallClient::connect`,
|
||||
@@ -153,8 +178,8 @@ configurable backoff:
|
||||
|
||||
```rust
|
||||
impl Hub {
|
||||
/// Dial a worker, discover its operations, and attach it to the
|
||||
/// aggregated env. Returns the worker's PeerId and the live
|
||||
/// Dial a call-protocol worker, discover its operations, and attach it
|
||||
/// to the aggregated env. Returns the worker's PeerId and the live
|
||||
/// CallConnection.
|
||||
pub async fn dial_worker(
|
||||
&self,
|
||||
@@ -177,13 +202,13 @@ impl Hub {
|
||||
|
||||
self.aggregated_env
|
||||
.write()
|
||||
.unwrap()
|
||||
.expect("aggregated env lock poisoned")
|
||||
.attach_peer(peer_id.clone(), connection.overlay_env());
|
||||
|
||||
Ok((peer_id, connection))
|
||||
}
|
||||
|
||||
/// Supervise an outbound worker connection: dial, discover, attach.
|
||||
/// Supervise an outbound call-protocol worker: dial, discover, attach.
|
||||
/// On disconnect, detach and retry with backoff. Runs until the
|
||||
/// Hub is dropped (the returned JoinHandle can be aborted).
|
||||
pub fn supervise_worker(
|
||||
@@ -269,6 +294,34 @@ The hub registers the built-in service discovery operations (`services/list`,
|
||||
`services/list-peers` handler returns each connected worker's operation list
|
||||
via `PeerCompositeEnv::peer_operations` (ADR-068).
|
||||
|
||||
### Extension points for future strategies
|
||||
|
||||
The call-protocol strategy is the first strategy. The hub's core peer
|
||||
lifecycle is protocol-agnostic — it manages connections and identities
|
||||
regardless of what protocol runs over them. Future strategies plug into the
|
||||
same lifecycle:
|
||||
|
||||
- **TTY strategy**: When a peer connects that supports `alknet/tty`, the hub
|
||||
registers the peer as a terminal session provider. A tty request routes to
|
||||
the right peer based on identity, not operation name. The strategy manages
|
||||
its own routing table (peer → tty backend), separate from
|
||||
`PeerCompositeEnv`.
|
||||
|
||||
- **Blobs strategy**: When a peer connects that supports `alknet/blobs`, the
|
||||
hub registers the peer as a blob store provider. Blob requests route by
|
||||
content hash or peer identity.
|
||||
|
||||
Each strategy is additive — it registers its own connection-establish and
|
||||
connection-drop hooks on the hub's core lifecycle. The strategies are
|
||||
independent; a single QUIC connection can carry multiple protocols over
|
||||
separate streams (QUIC multiplexing). The hub's core manages the connection;
|
||||
each strategy manages its own protocol-specific state.
|
||||
|
||||
These strategies are not specced here. The architecture keeps the door open
|
||||
by separating core lifecycle from protocol-specific strategy. The
|
||||
call-protocol strategy is the first and primary strategy because it handles
|
||||
the majority of real-world hub use cases.
|
||||
|
||||
### HubError
|
||||
|
||||
```rust
|
||||
@@ -298,11 +351,13 @@ pub enum HubError {
|
||||
the hub composes their ops. Worker A does not transitively see worker B's
|
||||
ops through the hub unless the hub explicitly re-exports them (ADR-029
|
||||
Assumption 5).
|
||||
- **Ownership reaping.** Stale ownership entries from autonomously-dead
|
||||
containers are tolerated (ADR-050 §4b). The hub does not add a reaper.
|
||||
- **Worker provisioning.** The hub does not spawn workers, configure them, or
|
||||
manage their lifecycle beyond connection supervision. Worker provisioning
|
||||
is an assembly-layer concern.
|
||||
- **Non-call-protocol strategies.** The tty, blobs, and other protocol
|
||||
strategies are not specced or implemented. The architecture keeps the door
|
||||
open by separating core peer lifecycle from protocol-specific strategy;
|
||||
each strategy is additive.
|
||||
|
||||
## Crate dependencies
|
||||
|
||||
|
||||
@@ -104,8 +104,13 @@ pub fn compose_root_env(
|
||||
.and_then(|s| s.overlay_for(context));
|
||||
|
||||
if let Some(aggregated) = &self.aggregated_env {
|
||||
// Clone the shared aggregated env (cheap — all fields are Arc).
|
||||
let mut env = aggregated.read().unwrap().clone();
|
||||
// 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
|
||||
@@ -146,10 +151,10 @@ let adapter = CallAdapter::new(registry, identity_provider)
|
||||
.with_aggregated_env(Arc::clone(&aggregated));
|
||||
```
|
||||
|
||||
The hub calls `aggregated.write().unwrap().attach_peer(peer_id, overlay)` on
|
||||
every worker connection-establish (after `from_call` populates the overlay)
|
||||
and `detach_peer(&peer_id)` on every disconnect. The `RwLock` write is held
|
||||
only for the `HashMap` insert/remove — connection-rate, not call-rate.
|
||||
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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user