feat: add Pub operation type, HandlerKind::Sink, call.published wire event (ADR-046)
The call protocol had Subscription (server→client streaming) but lacked the directional complement: client→server streaming, where the initiator produces a stream and the responder's handler consumes it. This gap was inherited from the @alkdev/pubsub EventEnvelope prior art, which has subscribe but no wire-level publish. ADR-046 adds the Pub primitive: - OperationType::Pub (client→server streaming) - OperationType::Subscription renamed to Sub (wire: "subscription" → "sub") - SinkHandler type + HandlerKind::Sink variant - PublishStream type alias (Stream<Item = Result<Value, CallError>>) - OperationRegistry::invoke_sink() dispatch path - call.published wire event (sixth event type, additive) - OperationSpec.publish_schema (Option<Value>, validates per-chunk input) - DispatchResult::Sink + SinkDispatch (handler future + chunk channel) - Dispatcher::pump_sink (feeds call.published chunks from wire to handler) - CallConnection::publish() / publish_with_payload() client methods - from_call sink forwarding handler (make_sink_forwarding_handler) - make_sink_handler() helper Fan-out/broker (one producer, N consumers, topic matching) is deferred to the channels session — the call protocol is point-to-point; the broker is a routing concern that sits above it. The Pub primitive is the load-bearing piece the broker will compose on. - 23 new tests (366 total, up from 343) - clippy clean, fmt clean Verification: cargo test — 366 passed cargo clippy --all-targets -- -D warnings — clean cargo fmt --check — clean
This commit is contained in:
@@ -98,15 +98,16 @@ are wire-stable and unchanged — see ADR-004.
|
||||
| [043](decisions/043-channelclient.md) | ChannelClient | Transport-agnostic from_connection |
|
||||
| [044](decisions/044-channels-subcrate-decomposition.md) | Channels Sub-Crate Decomposition | channels-core / channels-call (modules in alkcall) |
|
||||
| [045](decisions/045-alknetclient-native-dial-seam.md) | AlknetClient Dial Seam | spawn_dispatch / from_connection take-over; dial in consumer |
|
||||
| [046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | Publish Operation Type and HandlerKind::Sink | `OperationType::Pub` (client→server streaming); `SinkHandler` + `HandlerKind::Sink`; `call.published` wire event; `invoke_sink()` dispatch; `Subscription` renamed to `Sub` |
|
||||
|
||||
## Relevant Open Questions
|
||||
|
||||
See [open-questions.md](open-questions.md) for the full tracker. Key
|
||||
questions affecting this crate:
|
||||
|
||||
- **OQ-01**: Call protocol pub/sub primitive (open) — the call protocol
|
||||
has `subscribe` but no `pub`; needed for channels `channel/resources/
|
||||
subscribe` fan-out. This is the next ADR to write.
|
||||
- **OQ-01**: Call protocol pub/sub primitive (partially resolved) —
|
||||
ADR-046 adds the `Pub` primitive (client→server streaming). The
|
||||
fan-out/broker is deferred to channels.
|
||||
- **OQ-02**: Full channel-level flow-control windowing (deferred(scope))
|
||||
— bounded-buffer is decided (ADR-040); full windowing blocked on a real
|
||||
HOL-blocking deployment observation.
|
||||
|
||||
@@ -224,15 +224,16 @@ and browser adaptation, not a parallel implementation (see ADR-033).
|
||||
|
||||
### Event Types
|
||||
|
||||
Five event types carry request/response and subscription semantics:
|
||||
Six event types carry request/response, subscription, and publish semantics:
|
||||
|
||||
| Event | Direction | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| `call.requested` | Caller → Handler | Initiate a call or subscription |
|
||||
| `call.responded` | Handler → Caller | Deliver a result (one for calls, many for subscriptions) |
|
||||
| `call.completed` | Handler → Caller | Signal end of subscription stream |
|
||||
| `call.aborted` | Either side | Cancel the call/subscription |
|
||||
| `call.error` | Handler → Caller | Signal an error |
|
||||
| `call.requested` | Caller → Handler | Initiate a call, subscription, or publish |
|
||||
| `call.responded` | Handler → Caller | Deliver a result (one for calls, many for subscriptions, one for publish) |
|
||||
| `call.completed` | Either side | Signal end of subscription stream (handler→caller) or publish stream (caller→handler) |
|
||||
| `call.aborted` | Either side | Cancel the call/subscription/publish |
|
||||
| `call.error` | Either side | Signal an error |
|
||||
| `call.published` | Caller → Handler | One chunk of the published stream (Pub ops, ADR-046) |
|
||||
|
||||
**A call is a subscribe that resolves after one event.** Both `call()` and `subscribe()` send the same `call.requested` event. The difference is consumption pattern:
|
||||
- **call()**: Sends `call.requested`, resolves on first `call.responded`
|
||||
@@ -307,9 +308,10 @@ The `payload` field of `EventEnvelope` has a different shape per event type:
|
||||
|-------|----------------|
|
||||
| `call.requested` | `{ "operationId": "/fs/readFile", "input": {...}, "auth_token": "alk_..." (optional), "forwarded_for": { "id": "...", "scopes": [...], "resources": {} } (optional, ADR-026) }` |
|
||||
| `call.responded` | `{ "output": <Value> }` — the operation's output, matching `output_schema` |
|
||||
| `call.completed` | `{}` — empty object (subscription stream end signal) |
|
||||
| `call.completed` | `{}` — empty object (subscription stream end signal, or publish stream end signal) |
|
||||
| `call.aborted` | `{}` — empty object (cancellation signal; the `id` identifies which request) |
|
||||
| `call.error` | `{ "code": "...", "message": "...", "retryable": bool, "details": {...} (optional) }` |
|
||||
| `call.published` | `{ "input": <Value> }` — one chunk of the published stream (ADR-046) |
|
||||
|
||||
### `ResponseEnvelope` → `EventEnvelope` Conversion
|
||||
|
||||
@@ -324,12 +326,13 @@ The `request_id` becomes the `id` field. For subscriptions, each `call.responded
|
||||
|
||||
### Protocol Operations
|
||||
|
||||
The call protocol defines four top-level operations, expressed through event types and operation names:
|
||||
The call protocol defines five top-level operations, expressed through event types and operation names:
|
||||
|
||||
| Operation | Event Pattern | Description |
|
||||
|-----------|--------------|-------------|
|
||||
| **call** | `call.requested` → `call.responded` or `call.error` | Request/response — one result |
|
||||
| **subscribe** | `call.requested` → many `call.responded` → `call.completed` or `call.aborted` | Streaming — zero or more results |
|
||||
| **subscribe** | `call.requested` → many `call.responded` → `call.completed` or `call.aborted` | Streaming — zero or more results (server→client) |
|
||||
| **publish** | `call.requested` → many `call.published` → `call.completed` → `call.responded` or `call.error` | Client→server streaming — initiator publishes a stream, responder returns one result (ADR-046) |
|
||||
| **batch** | multiple `call.requested` (different IDs) → multiple `call.responded` | Multiple operations in one round |
|
||||
| **schema** | `call.requested` name `services/list` or `services/schema` → `call.responded` | Discover available operations |
|
||||
|
||||
@@ -577,7 +580,8 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
|
||||
| Peer-graph routing model (supersedes ADR-023) | [ADR-024](decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
|
||||
| Forwarded-for identity | [ADR-026](decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
|
||||
| Operation error schemas | [ADR-016](decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details` |
|
||||
| Streaming handler for subscriptions | [ADR-021](decisions/049-streaming-handler-for-subscriptions.md) | `StreamingHandler` type, `invoke_streaming()` dispatch path, `INVALID_OPERATION_TYPE` protocol code; the server-side streaming branch in `handle_stream` |
|
||||
| Streaming handler for subscriptions | [ADR-021](decisions/021-streaming-handler-for-subscriptions.md) | `StreamingHandler` type, `invoke_streaming()` dispatch path, `INVALID_OPERATION_TYPE` protocol code; the server-side streaming branch in `handle_stream` |
|
||||
| Publish operation type and HandlerKind::Sink | [ADR-046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | `OperationType::Pub` (client→server streaming); `SinkHandler` + `HandlerKind::Sink`; `call.published` wire event; `invoke_sink()` dispatch; `Subscription` renamed to `Sub` |
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
# ADR-046: Publish Operation Type and `HandlerKind::Sink`
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The call protocol has `Subscription` (server→client streaming: a
|
||||
`StreamingHandler` *produces* a stream of `call.responded` events,
|
||||
ADR-021). It lacks the directional complement: **client→server
|
||||
streaming**, where the initiator produces a stream and the responder's
|
||||
handler *consumes* it. This gap was inherited from the
|
||||
`@alkdev/pubsub` `EventEnvelope` prior art, which has a `subscribe`
|
||||
primitive but no `publish` primitive in its `PubSub` type
|
||||
(`create_pubsub.ts:48-58` — `publish` there dispatches to an in-process
|
||||
`EventTarget`, not over the wire).
|
||||
|
||||
The gap surfaced while speccing the channels crate's
|
||||
`channel/resources/subscribe` operation (ADR-037): the hub aggregates
|
||||
worker resources and must push live updates to N browser subscribers
|
||||
when any worker's resource set changes. Each browser's `subscribe`
|
||||
arrives on the hub's channel 0, but the worker resource changes arrive
|
||||
on separate worker connections. The hub needs to fan-out.
|
||||
|
||||
The research findings
|
||||
(`/workspace/@alkdev/alknet/docs/research/call-channels-unification/
|
||||
findings.md`) identified two separable concerns:
|
||||
|
||||
- **(A) Client→server streaming primitive.** A new `OperationType::Pub`
|
||||
where the initiator streams data to the responder. This is a
|
||||
call-protocol primitive — it extends the existing
|
||||
`Query`/`Mutation`/`Subscription` set with the missing direction.
|
||||
- **(B) Fan-out / broker.** A producer pushes events to subscribers
|
||||
it didn't directly receive a `call.requested` from. This is a broker
|
||||
concern that sits *above* the call protocol — the hub-as-broker
|
||||
pattern (ADR-042 hub relay) composes on top of the call protocol's
|
||||
primitives.
|
||||
|
||||
This ADR implements **(A) only** — the `Pub` primitive in the call
|
||||
protocol. **(B) is deferred to the channels session** (OQ-22 §fan-out
|
||||
scope). The rationale: the call protocol's job is point-to-point (one
|
||||
initiator, one responder, a stream between them); fan-out is a routing
|
||||
concern with a different lifecycle (a topic registry that outlives
|
||||
individual calls) and its first real consumer (the channels
|
||||
`channel/resources/subscribe` / hub-as-broker) isn't implemented yet.
|
||||
Designing the broker blind would produce a half-design. The `Pub`
|
||||
primitive is the load-bearing call-protocol piece; the broker composes
|
||||
on top of it later, in channels, the same way the hub relay composes on
|
||||
top of existing call ops.
|
||||
|
||||
### Gap A from the research findings: `Pub` handler shape
|
||||
|
||||
The research findings' Gap A identified that the current
|
||||
`StreamingHandler` is inherently **server→client** — the handler
|
||||
*produces* a `Stream<Item = ResponseEnvelope>`:
|
||||
|
||||
```rust
|
||||
pub type StreamingHandler = Arc<
|
||||
dyn Fn(Value, OperationContext) -> Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>
|
||||
+ Send + Sync,
|
||||
>;
|
||||
```
|
||||
|
||||
For `Pub`, the initiator is the producer streaming *to* the responder.
|
||||
The handler on the responder side needs to *consume* a stream from the
|
||||
initiator, not produce one. Three candidate shapes were considered
|
||||
(findings.md §Gap A):
|
||||
|
||||
- **(a) New `HandlerKind::Sink`** — `Fn(Value, OperationContext,
|
||||
RecvStream) -> Future<Output = ResponseEnvelope>`. The `RecvStream`
|
||||
is the initiator's data stream. Clean type-level separation; required
|
||||
if direct `Pub` (client→server streaming without a hub) is a use
|
||||
case.
|
||||
- **(b) `BiStream` on `OperationContext`** — muddies the handler
|
||||
signature; every handler gets a `BiStream` it may not need.
|
||||
- **(c) `Pub` is purely a broker concept** — no new handler; the broker
|
||||
matches `Pub` to `Sub` and proxies. Only works if `Pub` always goes
|
||||
through a broker.
|
||||
|
||||
**Decision: (a).** Direct `Pub` (client→server streaming without a hub)
|
||||
is a use case — a worker uploading a file stream to a hub's
|
||||
`fs/writeStream` op, a client pushing telemetry to a `metrics/ingest`
|
||||
op, a browser streaming a drag-drop file to a `fs/upload` op. These
|
||||
don't need a broker; they need a handler that consumes a stream and
|
||||
returns a result. `HandlerKind::Sink` is the type-level expression of
|
||||
that.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Rename `OperationType::Subscription` → `Sub`
|
||||
|
||||
The existing `Subscription` variant is renamed to `Sub` for symmetry
|
||||
with `Pub`. The wire-format `op_type` string changes from `"subscription"`
|
||||
to `"sub"`. No deployments exist, so this is a cosmetic rename with no
|
||||
backward-compat concern. The new wire enum:
|
||||
|
||||
```rust
|
||||
pub enum OperationType {
|
||||
Query, // request → single JSON response (unchanged)
|
||||
Mutation, // request → single JSON response (unchanged)
|
||||
Sub, // client subscribes, server streams (was Subscription)
|
||||
Pub, // client publishes a stream to the server (new)
|
||||
}
|
||||
```
|
||||
|
||||
Wire `op_type` strings: `"query"`, `"mutation"`, `"sub"`, `"pub"`.
|
||||
|
||||
The old `"subscription"` string is **not** accepted on input — no
|
||||
deployments exist, no backward-compat shim. Clients that sent
|
||||
`"subscription"` were never written.
|
||||
|
||||
### 2. `OperationType::Pub` — client→server streaming
|
||||
|
||||
`Pub` is the directional complement to `Sub`. Both are streaming
|
||||
operations, but the stream flows the opposite direction:
|
||||
|
||||
| `OperationType` | Stream direction | Initiator role | Responder role | Handler kind |
|
||||
|-----------------|-----------------|---------------|----------------|-------------|
|
||||
| `Sub` (was `Subscription`) | server → client | consumer (subscribes) | producer (streams) | `Stream` (`StreamingHandler`) |
|
||||
| `Pub` (new) | client → server | producer (publishes) | consumer (receives) | `Sink` (`SinkHandler`) |
|
||||
|
||||
`Sub` and `Pub` are independent operations with independent specs and
|
||||
ACLs. A peer that may publish a stream is not the same grant as a peer
|
||||
that may subscribe to one. The op-type split enables the hub-as-proxy
|
||||
pattern: a worker publishes to the hub, the hub owns the resource, and
|
||||
consumers subscribe from the hub (research findings §hub-as-proxy). But
|
||||
that pattern is the broker layer (deferred); the primitive is just the
|
||||
two directions.
|
||||
|
||||
### 3. `SinkHandler` type and `HandlerKind::Sink`
|
||||
|
||||
A new handler kind consumes the initiator's stream and returns a single
|
||||
`ResponseEnvelope`:
|
||||
|
||||
```rust
|
||||
/// Sink handler — `Pub` operations. Receives the initiator's data
|
||||
/// stream and returns a single `ResponseEnvelope` (the result of
|
||||
/// consuming the stream). Each `Ok(value)` published by the initiator
|
||||
/// arrives as an item in the `RecvStream`; the handler processes them
|
||||
/// and returns one result (success or error).
|
||||
pub type SinkHandler = Arc<
|
||||
dyn Fn(
|
||||
Value,
|
||||
OperationContext,
|
||||
Pin<Box<dyn Stream<Item = Value> + Send>>,
|
||||
) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
pub enum HandlerKind {
|
||||
Once(Handler),
|
||||
Stream(StreamingHandler),
|
||||
Sink(SinkHandler),
|
||||
}
|
||||
```
|
||||
|
||||
The `Pin<Box<dyn Stream<Item = Value> + Send>>` is the initiator's
|
||||
published stream — each `call.published` event's `payload.input` yields
|
||||
one `Value` item. The handler consumes the stream to completion, then
|
||||
returns a single `ResponseEnvelope`. An `Err` in the stream (a
|
||||
`call.error` from the initiator) terminates the stream early; the
|
||||
handler may produce its `ResponseEnvelope` from the partial input or
|
||||
return the initiator's error.
|
||||
|
||||
Registration validates: `Pub` → `HandlerKind::Sink`. Mismatch is a
|
||||
startup error (same pattern as ADR-021's `Subscription` → `Stream`
|
||||
validation).
|
||||
|
||||
A `make_sink_handler()` helper (analogue of `make_handler()` /
|
||||
`make_streaming_handler()`) wraps a sink-consuming closure into a
|
||||
`SinkHandler`.
|
||||
|
||||
### 4. `OperationSpec` gains `publish_schema`
|
||||
|
||||
`Pub` operations have an input stream, not a single input. The
|
||||
`input_schema` validates the initial `call.requested` payload's `input`
|
||||
(the "open" parameters — e.g., the file path for `fs/upload`). A new
|
||||
optional `publish_schema` validates each `call.published` chunk's
|
||||
`input` (the stream items — e.g., each file chunk):
|
||||
|
||||
```rust
|
||||
pub struct OperationSpec {
|
||||
// ... existing fields ...
|
||||
pub input_schema: Value, // validates call.requested input (the open params)
|
||||
pub output_schema: Value, // validates call.responded output (the result)
|
||||
/// Schema for each published chunk's `input` (Pub ops only).
|
||||
/// `None` for Query/Mutation/Sub ops. When set, the dispatch
|
||||
/// path validates each `call.published` event's payload.input
|
||||
/// against this schema before yielding it to the SinkHandler.
|
||||
/// When `None` (Pub op with no per-chunk validation), chunks
|
||||
/// are yielded as-is.
|
||||
pub publish_schema: Option<Value>,
|
||||
}
|
||||
```
|
||||
|
||||
`OperationSpec::new()` defaults `publish_schema` to `None`
|
||||
(Query/Mutation/Sub). A builder method or direct field construction sets
|
||||
it for `Pub` ops. This mirrors how `resource_id_path` is `Option` and
|
||||
defaults to `None` — additive, non-breaking for existing specs.
|
||||
|
||||
### 5. New wire event: `call.published`
|
||||
|
||||
A sixth event type carries the initiator's stream chunks:
|
||||
|
||||
| Event | Direction | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| `call.requested` | initiator → responder | Open the Pub (operationId + initial input) |
|
||||
| `call.published` | initiator → responder | One chunk of the published stream |
|
||||
| `call.responded` | responder → initiator | The result (single, after stream ends) |
|
||||
| `call.completed` | responder → initiator | (Not used for Pub — the `call.responded` is the terminal event) |
|
||||
| `call.aborted` | either side | Cancel the Pub |
|
||||
| `call.error` | either side | Error (initiator publish error or responder handler error) |
|
||||
|
||||
`call.published` payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"input": <Value>
|
||||
}
|
||||
```
|
||||
|
||||
The `input` is one published chunk, validated against `publish_schema`
|
||||
(if set). The `id` field is the request ID (same correlation key as
|
||||
`call.requested`).
|
||||
|
||||
AGENTS.md §7: "New event types may be added; existing ones must not
|
||||
change shape." `call.published` is additive — existing event types are
|
||||
unchanged.
|
||||
|
||||
**Stream lifecycle for Pub:**
|
||||
|
||||
1. Initiator sends `call.requested` with `operationId`, `input` (open
|
||||
params).
|
||||
2. Initiator sends zero or more `call.published` events with the same
|
||||
`id`, each carrying one chunk.
|
||||
3. Initiator signals stream end by sending a `call.completed` event
|
||||
(reusing the existing event type in the initiator→responder
|
||||
direction — it signals "I'm done publishing"). Alternatively, the
|
||||
initiator closes the write half of the stream (transport-level EOF).
|
||||
The responder detects end-of-stream and delivers `None` to the
|
||||
`SinkHandler`.
|
||||
4. Responder's `SinkHandler` consumes the stream, returns one
|
||||
`ResponseEnvelope`.
|
||||
5. Responder sends `call.responded` (the result) or `call.error`.
|
||||
|
||||
**Why `call.completed` initiator→responder instead of write-half close?**
|
||||
Both work. `call.completed` is explicit and works over transports where
|
||||
write-half close is ambiguous (WebSocket, Worker `postMessage`). The
|
||||
dispatch path treats either as stream end. The client API
|
||||
(`CallConnection::publish`) sends `call.completed` by default; the
|
||||
transport-agnostic `Connection::open_bi` write-half close is the
|
||||
fallback.
|
||||
|
||||
### 6. `OperationRegistry::invoke_sink()` dispatch path
|
||||
|
||||
```rust
|
||||
impl OperationRegistry {
|
||||
/// Dispatch a Pub operation. The `publish_stream` is the
|
||||
/// initiator's data stream (each `call.published` chunk's input).
|
||||
/// Pre-handler errors (not-found, forbidden, invalid operation
|
||||
/// type) yield a single error `ResponseEnvelope`.
|
||||
pub async fn invoke_sink(
|
||||
&self,
|
||||
name: &str,
|
||||
input: Value,
|
||||
publish_stream: Pin<Box<dyn Stream<Item = Result<Value, CallError>> + Send>>,
|
||||
context: OperationContext,
|
||||
) -> ResponseEnvelope;
|
||||
}
|
||||
```
|
||||
|
||||
`invoke_sink()` performs the same visibility + ACL checks as `invoke()`
|
||||
and `invoke_streaming()`, then dispatches to the `SinkHandler` with the
|
||||
`publish_stream`. Pre-handler errors (not-found, forbidden,
|
||||
`INVALID_OPERATION_TYPE` for a non-Pub op) return a single error
|
||||
`ResponseEnvelope`.
|
||||
|
||||
The `publish_stream` carries `Result<Value, CallError>` — `Ok(value)`
|
||||
for a published chunk, `Err(call_error)` for an initiator-side error
|
||||
(a `call.error` event from the initiator). An `Err` terminates the
|
||||
stream; the handler may produce its result from the partial input or
|
||||
propagate the error.
|
||||
|
||||
`invoke()` on a `Pub` op and `invoke_streaming()` on a `Pub` op both
|
||||
return `INVALID_OPERATION_TYPE` (same pattern as ADR-021's
|
||||
`invoke()` on `Subscription`). `OperationEnv::invoke()` (composition)
|
||||
is request/response-only and errors with `INVALID_OPERATION_TYPE` on
|
||||
`Pub` ops — sink composition (consume a child's stream) is a
|
||||
handler-level concern, not a protocol composition concern (same
|
||||
boundary as ADR-021's stream composition).
|
||||
|
||||
### 7. `Dispatcher::handle_stream` sink branch
|
||||
|
||||
`DispatchResult` gains a `Sink` variant:
|
||||
|
||||
```rust
|
||||
pub enum DispatchResult {
|
||||
Once(ResponseEnvelope),
|
||||
Stream(ResponseStream),
|
||||
Sink(Pin<Box<dyn Stream<Item = Result<Value, CallError>> + Send>>),
|
||||
}
|
||||
```
|
||||
|
||||
`dispatch()` branches on `op_type`:
|
||||
|
||||
- `Query` / `Mutation` → `invoke()` → `DispatchResult::Once`
|
||||
- `Sub` → `invoke_streaming()` → `DispatchResult::Stream`
|
||||
- `Pub` → `invoke_sink()` → `DispatchResult::Sink`
|
||||
|
||||
`handle_stream` gains a sink branch: after dispatching a
|
||||
`call.requested` for a `Pub` op, the loop continues reading
|
||||
`call.published` events (same `id`), feeding each chunk's `input` into
|
||||
the `publish_stream`. On `call.completed` (initiator→responder) or
|
||||
stream read end, the `publish_stream` ends. The `SinkHandler` runs
|
||||
concurrently, consuming the `publish_stream` and producing one
|
||||
`ResponseEnvelope`, which is written to the wire as `call.responded` or
|
||||
`call.error`.
|
||||
|
||||
The sink branch sets `deadline: None` for `Pub` ops (unbounded — the
|
||||
stream may be long-lived, same as `Sub`).
|
||||
|
||||
### 8. `CallConnection::publish()` client method
|
||||
|
||||
```rust
|
||||
impl CallConnection {
|
||||
/// Publish a stream to a remote `Pub` operation. Sends
|
||||
/// `call.requested` (open), then one `call.published` per item in
|
||||
/// `stream`, then `call.completed` (stream end). Returns the
|
||||
/// responder's single `ResponseEnvelope`.
|
||||
pub async fn publish(
|
||||
&self,
|
||||
operation_id: &str,
|
||||
input: Value,
|
||||
stream: Pin<Box<dyn Stream<Item = Value> + Send>>,
|
||||
) -> ResponseEnvelope;
|
||||
}
|
||||
```
|
||||
|
||||
The client opens a bidirectional stream, sends `call.requested`, pumps
|
||||
the `stream` into `call.published` events, sends `call.completed`, then
|
||||
reads the single `call.responded` / `call.error` response. The
|
||||
`PendingRequestMap` correlates by request ID (same as `call()`).
|
||||
|
||||
### 9. `from_call` sink forwarding
|
||||
|
||||
The `from_call` forwarding handler construction branches on `op_type`
|
||||
during discovery (ADR-021 §8 added the streaming branch; this adds the
|
||||
sink branch):
|
||||
|
||||
- `Query` / `Mutation` → `make_forwarding_handler()` (single response),
|
||||
`HandlerKind::Once`.
|
||||
- `Sub` → `make_streaming_forwarding_handler()` (remote stream → local
|
||||
stream), `HandlerKind::Stream`.
|
||||
- `Pub` → `make_sink_forwarding_handler()` (local stream → remote
|
||||
`call.published` events), `HandlerKind::Sink`. The forwarding handler
|
||||
receives the local `publish_stream`, opens a `call.requested` +
|
||||
`call.published` pump to the remote, and returns the remote's single
|
||||
`ResponseEnvelope`.
|
||||
|
||||
A `from_call`-imported `Pub` op forwards the stream end-to-end: the
|
||||
local `SinkHandler`'s stream is pumped to the remote as
|
||||
`call.published` events; the remote's `call.responded` is the local
|
||||
handler's result.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- `Pub` operations work end-to-end: client-side `publish()` →
|
||||
server-side `invoke_sink()` → `SinkHandler` → `call.responded`. The
|
||||
directional complement to `Sub` is in place.
|
||||
- The `Handler` / `StreamingHandler` / `SinkHandler` triple mirrors the
|
||||
three operation shapes: request/response, server→client stream,
|
||||
client→server stream. The `HandlerKind` enum (`Once` | `Stream` |
|
||||
`Sink`) makes the "one handler kind per op type" invariant
|
||||
type-level.
|
||||
- `Sub` rename aligns the wire enum with the conceptual symmetry
|
||||
(`sub`/`pub`). No deployments exist; the rename is free.
|
||||
- `call.published` is additive — existing event types are unchanged,
|
||||
per the stable wire-format constraint (AGENTS.md §7).
|
||||
- `publish_schema` on `OperationSpec` is additive (`Option`, defaults
|
||||
`None`) — existing specs are unchanged.
|
||||
- The fan-out/broker is cleanly deferred. The `Pub` primitive is the
|
||||
building block; the channels session composes the broker on top.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- `OperationType::Subscription` → `Sub` is a rename across the codebase
|
||||
(spec, registry, dispatch, discovery, tests, docs). Mechanical but
|
||||
wide.
|
||||
- `HandlerKind` gains a third variant. Existing code matching
|
||||
`HandlerKind` must handle `Sink`. The builder gains
|
||||
`with_local_sink` / `with_leaf_sink` methods.
|
||||
- `OperationSpec::new()` gains a `publish_schema` parameter (or a
|
||||
builder method). Every spec-constructing site adds the field,
|
||||
defaulting to `None`. This is a mechanical change.
|
||||
- `call.published` is a new wire event type. Existing clients that
|
||||
don't know about it ignore it (the dispatch loop's existing "ignore
|
||||
unknown event types" behavior). Clients that want to `Pub` must be
|
||||
updated; clients that only `call`/`subscribe` are unaffected.
|
||||
- The `Dispatcher::handle_stream` sink branch adds a
|
||||
publish-stream-pump alongside the existing response-stream-pump. New
|
||||
code in the hot dispatch path, but it's a straightforward
|
||||
`while let Some(chunk) = publish_stream.next().await` loop feeding
|
||||
the `SinkHandler`.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way.** The `Handler` / `StreamingHandler` / `SinkHandler` /
|
||||
`HandlerKind` API surface is what handlers are written against across
|
||||
crates. Changing it after handlers exist is a rewrite. The
|
||||
`call.published` wire event is one-way — once emitted, clients may
|
||||
handle it, and removing it would break those handlers. The
|
||||
`OperationType::Sub` rename is one-way (the wire string `"sub"` is now
|
||||
the canonical form; `"subscription"` is gone).
|
||||
|
||||
The `HandlerKind` enum shape (`Once(Handler) | Stream(StreamingHandler)
|
||||
| Sink(SinkHandler)`) is the one-way commitment: three handler variants,
|
||||
validated against `op_type`. The concrete `Pin<Box<dyn Stream<Item =
|
||||
Value> + Send>>` choice for the sink's input stream is a two-way-door
|
||||
implementation detail within the one-way decision.
|
||||
|
||||
The `publish_schema: Option<Value>` field on `OperationSpec` is a
|
||||
one-way addition (the field is part of the spec's public API); its
|
||||
`Option`/`None`-default keeps it non-breaking for existing specs.
|
||||
|
||||
## Fan-out (deferred — OQ-22 §fan-out scope)
|
||||
|
||||
The fan-out / broker mechanism (one producer, N consumers, topic
|
||||
matching by `(op_name, params_hash)`) is **not** part of this ADR. It
|
||||
is deferred to the channels session for these reasons:
|
||||
|
||||
1. **The call protocol is point-to-point.** One initiator, one
|
||||
responder, a stream between them. A topic registry that outlives
|
||||
individual calls is a different lifecycle and a different concern.
|
||||
2. **The broker's first consumer is channels.** The
|
||||
`channel/resources/subscribe` operation (ADR-037) and the
|
||||
hub-as-broker pattern (ADR-042) are channels-layer concerns. The
|
||||
broker naturally lives there.
|
||||
3. **Designing the broker blind produces a half-design.** The research
|
||||
findings' Gap B explicitly calls out that the broker is "a new
|
||||
component with no spec." Speccing it without its first real consumer
|
||||
(channels isn't implemented yet) risks a design that channels has to
|
||||
work around rather than compose with.
|
||||
|
||||
The `Pub` primitive this ADR adds is the load-bearing piece the broker
|
||||
composes on. The channels session will add the broker (topic registry,
|
||||
`Pub`↔`Sub` matching, fan-out) on top — the same way the hub relay
|
||||
(ADR-042) composes on top of existing call ops.
|
||||
|
||||
OQ-22 is resolved for the primitive (the `Pub` op type + handler + wire
|
||||
event). The fan-out scope remains open as a channels-session concern;
|
||||
the OQ entry is updated to reflect this split.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-021: Streaming Handler for Subscriptions (the `StreamingHandler`
|
||||
/ `HandlerKind::Stream` / `invoke_streaming()` pattern this ADR
|
||||
mirrors for `SinkHandler` / `HandlerKind::Sink` / `invoke_sink()`)
|
||||
- ADR-015: Call Protocol Stream Model (the five event types; this ADR
|
||||
adds the sixth, `call.published`)
|
||||
- ADR-014: Hand-Rolled EventEnvelope Framing (the wire format this ADR
|
||||
extends)
|
||||
- ADR-018: Handler Registration, Provenance, and Composition Authority
|
||||
(`HandlerRegistration` gains the `Sink` variant)
|
||||
- ADR-017: Privilege Model and Authority Context (visibility/ACL checks
|
||||
run identically in `invoke_sink()` as in `invoke()` /
|
||||
`invoke_streaming()`)
|
||||
- ADR-020: Abort Cascade for Nested Calls (stream drop on abort; cascade
|
||||
through sink handlers)
|
||||
- ADR-032: One-Way Door Decision Framework (the `SinkHandler` split is a
|
||||
one-way door — handler API surface)
|
||||
- `@alkdev/pubsub/src/create_pubsub.ts` — the `PubSub` type with
|
||||
`publish` / `subscribe` (the in-process `EventTarget` dispatch the
|
||||
wire-level `Pub` primitive complements)
|
||||
- `/workspace/@alkdev/alknet/docs/research/call-channels-unification/
|
||||
findings.md` — the research that identified the pub/sub gap, Gap A
|
||||
(handler shape), and the fan-out deferral
|
||||
- OQ-22: Call protocol pub/sub primitive (resolved for the primitive by
|
||||
this ADR; fan-out scope deferred to channels)
|
||||
- Spec documents amended: `call-protocol.md`, `operation-registry.md`,
|
||||
`open-questions.md`, `README.md`
|
||||
@@ -37,30 +37,36 @@ status, priority, and (when resolved) a resolution citing the ADR.
|
||||
| OQ-20 | ~~API key asymmetry~~ | dissolved | medium | PeerEntry supports multiple credential paths |
|
||||
| OQ-21 | X.509 outgoing-only case | resolved | medium | Three remote roles; PeerEntry asymmetry correct |
|
||||
|
||||
## Call Protocol — Pub/Sub (NEW)
|
||||
## Call Protocol — Pub/Sub
|
||||
|
||||
| OQ | Title | Status | Priority | Resolution |
|
||||
|----|-------|--------|----------|------------|
|
||||
| OQ-22 | Call protocol pub/sub primitive — pub to go with sub | open | high | Not yet resolved. The call protocol has `subscribe` (consumer → producer: "send me a stream") but no `pub` (producer pushes to subscribers it didn't directly receive a call.requested from). Surfaced during channels spec work — `channel/resources/subscribe` (ADR-037) needs fan-out. See §"Pub/Sub Gap" below. |
|
||||
| OQ-22 | Call protocol pub/sub primitive — pub to go with sub | partially resolved | high | ADR-046 resolves the primitive: `OperationType::Pub` + `HandlerKind::Sink` + `call.published` wire event + `invoke_sink()` dispatch path. The fan-out/broker mechanism (one producer, N consumers, topic matching) is deferred to the channels session — the call protocol is point-to-point; the broker is a routing concern that sits above it. See §"Pub/Sub Gap" below. |
|
||||
|
||||
### Pub/Sub Gap
|
||||
|
||||
The call protocol's `StreamingHandler` / `invoke_streaming()` path
|
||||
(ADR-021) is point-to-point: a `call.requested` arrives, the handler
|
||||
produces a stream of `call.responded` events back to that one caller.
|
||||
There is no mechanism for a producer to push events to subscribers that
|
||||
it didn't directly receive a `call.requested` from.
|
||||
There was no mechanism for a producer to stream data *to* a responder
|
||||
(client→server streaming), and no fan-out (one producer, N consumers).
|
||||
|
||||
The first consumer that needs this is the channels `channel/resources/
|
||||
subscribe` operation (ADR-037): the hub aggregates worker resources and
|
||||
needs to push live updates to N browser subscribers when any worker's
|
||||
resource set changes. Each browser's `subscribe` arrives on the hub's
|
||||
channel 0, but the worker resource changes arrive on separate worker
|
||||
connections. The hub needs to fan-out.
|
||||
**ADR-046** resolves the directional gap: `OperationType::Pub` is the
|
||||
client→server streaming complement to `Sub` (was `Subscription`,
|
||||
renamed for symmetry). `HandlerKind::Sink` is the consuming handler
|
||||
type. `call.published` is the wire event carrying stream chunks.
|
||||
`invoke_sink()` is the dispatch path. `CallConnection::publish()` is
|
||||
the client method. The `Subscription` variant is renamed to `Sub`
|
||||
(wire string `"sub"`).
|
||||
|
||||
**Blocked on**: An ADR specifying the pub primitive's shape — topic-based
|
||||
fan-out vs. producer-side push to existing subscriptions. This is the
|
||||
next ADR to write (alkcall ADR-046).
|
||||
**Fan-out deferred.** The broker (topic registry, `Pub`↔`Sub` matching,
|
||||
N-consumer fan-out) is deferred to the channels session. The call
|
||||
protocol is point-to-point (one initiator, one responder, a stream
|
||||
between them); a topic registry that outlives individual calls is a
|
||||
different lifecycle and a different concern. The broker's first
|
||||
consumer is the channels `channel/resources/subscribe` operation
|
||||
(ADR-037) and the hub-as-broker pattern (ADR-042). The `Pub` primitive
|
||||
is the load-bearing piece the broker composes on.
|
||||
|
||||
## Channels
|
||||
|
||||
|
||||
@@ -53,7 +53,8 @@ pub struct OperationSpec {
|
||||
pub enum OperationType {
|
||||
Query, // Read-only, idempotent (e.g., "fs/readFile", "services/list")
|
||||
Mutation, // Side effects (e.g., "bash/exec", "github/authenticate")
|
||||
Subscription, // Streaming (e.g., "agent/chat", "events/subscribe")
|
||||
Sub, // Server→client streaming (e.g., "agent/chat", "events/subscribe") — was Subscription (ADR-046)
|
||||
Pub, // Client→server streaming (e.g., "fs/upload") — ADR-046
|
||||
}
|
||||
|
||||
pub enum Visibility {
|
||||
@@ -154,10 +155,11 @@ Operations with empty `AccessControl` (no required scopes, no resource checks) a
|
||||
|
||||
### Handler
|
||||
|
||||
There are two handler types, one per dispatch shape — mirroring the
|
||||
There are three handler types, one per dispatch shape — mirroring the
|
||||
TypeScript prior art (`@alkdev/operations/src/types.ts:62-78`:
|
||||
`OperationHandler` returns a single value; `SubscriptionHandler` returns an
|
||||
`AsyncGenerator`). The split is locked by ADR-021.
|
||||
`AsyncGenerator`). The split is locked by ADR-021 (Once/Stream) and
|
||||
ADR-046 (Sink).
|
||||
|
||||
```rust
|
||||
/// Request/response handler — Query and Mutation operations.
|
||||
@@ -166,7 +168,7 @@ pub type Handler = Arc<
|
||||
+ Send + Sync,
|
||||
>;
|
||||
|
||||
/// Streaming handler — Subscription operations. Returns a stream of
|
||||
/// Streaming handler — Sub operations. Returns a stream of
|
||||
/// ResponseEnvelopes: each Ok(value) → call.responded, an Err → call.error
|
||||
/// (terminal — stream ends), natural stream end → call.completed.
|
||||
pub type StreamingHandler = Arc<
|
||||
@@ -175,6 +177,20 @@ pub type StreamingHandler = Arc<
|
||||
+ Send + Sync,
|
||||
>;
|
||||
|
||||
/// Sink handler — Pub operations (ADR-046). Receives the initiator's
|
||||
/// data stream (each `call.published` chunk's `input` as one `Value`
|
||||
/// item) and returns a single `ResponseEnvelope` (the result of
|
||||
/// consuming the stream).
|
||||
pub type SinkHandler = Arc<
|
||||
dyn Fn(
|
||||
Value,
|
||||
OperationContext,
|
||||
Pin<Box<dyn Stream<Item = Result<Value, CallError>> + Send>>,
|
||||
) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
/// Type alias for the boxed stream shape used by `invoke_streaming()` and
|
||||
/// `StreamingHandler` return values. The concrete library
|
||||
/// (`futures::stream::BoxStream<'static, T>` = `Pin<Box<dyn Stream<Item = T>
|
||||
@@ -182,6 +198,17 @@ pub type StreamingHandler = Arc<
|
||||
/// exists so the two spellings (the expanded form in `StreamingHandler` and
|
||||
/// the short form in `invoke_streaming()`) refer to the same type.
|
||||
pub type ResponseStream = Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>;
|
||||
|
||||
/// The publish stream shape consumed by a `SinkHandler` (ADR-046). Each
|
||||
/// item is one published chunk: `Ok(value)` for a `call.published` event,
|
||||
/// `Err(call_error)` for an initiator-side error.
|
||||
pub type PublishStream = Pin<Box<dyn Stream<Item = Result<Value, CallError>> + Send>>;
|
||||
|
||||
pub enum HandlerKind {
|
||||
Once(Handler),
|
||||
Stream(StreamingHandler),
|
||||
Sink(SinkHandler),
|
||||
}
|
||||
```
|
||||
|
||||
Both handlers are async — many operations (file I/O, HTTP service calls,
|
||||
@@ -933,6 +960,7 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
|
||||
| Forwarded-for identity | [ADR-026](decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `OperationContext` and `call.requested`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
|
||||
| Streaming handler for subscriptions | [ADR-021](decisions/049-streaming-handler-for-subscriptions.md) | `StreamingHandler` type alongside `Handler`; `HandlerKind` enum on `HandlerRegistration` validated against `op_type`; `invoke_streaming()` on `OperationRegistry`; `invoke()` and `OperationEnv::invoke()` error with `INVALID_OPERATION_TYPE` on `Subscription` ops; composition stays request/response-only, stream composition is handler-level |
|
||||
| Dynamic resource ownership for runtime-spawned resources | [ADR-011](decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | `AccessControl::check` consults an `OwnershipProvider` (sync read trait, ADR-033 repo/adapter pattern); `OperationSpec` gains `resource_id_path` (JSON pointer into the input); proxy-only access pattern (spawner owns, proxy to share, teardown revokes); `list` = scope-gate + result-filter; teardown = automatic, handler-driven; composition = two orthogonal checks, ADR-017/022 unchanged |
|
||||
| Publish operation type and HandlerKind::Sink | [ADR-046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | `OperationType::Pub` (client→server streaming); `SinkHandler` + `HandlerKind::Sink` + `PublishStream`; `invoke_sink()` dispatch path; `call.published` wire event; `OperationSpec.publish_schema`; `Subscription` renamed to `Sub`; fan-out/broker deferred to channels |
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::protocol::connection::CallConnection;
|
||||
use crate::protocol::wire::ResponseEnvelope;
|
||||
use crate::registry::context::OperationContext;
|
||||
use crate::registry::registration::{
|
||||
Handler, HandlerKind, HandlerRegistration, OperationProvenance, StreamingHandler,
|
||||
Handler, HandlerKind, HandlerRegistration, OperationProvenance, SinkHandler, StreamingHandler,
|
||||
};
|
||||
use crate::registry::spec::{
|
||||
AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility,
|
||||
@@ -124,7 +124,11 @@ fn build_bundles(
|
||||
}
|
||||
|
||||
let kind = match spec.op_type {
|
||||
OperationType::Subscription => HandlerKind::Stream(make_streaming_forwarding_handler(
|
||||
OperationType::Sub => HandlerKind::Stream(make_streaming_forwarding_handler(
|
||||
Arc::new(op_summary.connection.clone()),
|
||||
remote_name,
|
||||
)),
|
||||
OperationType::Pub => HandlerKind::Sink(make_sink_forwarding_handler(
|
||||
Arc::new(op_summary.connection.clone()),
|
||||
remote_name,
|
||||
)),
|
||||
@@ -254,7 +258,8 @@ fn parse_op_type(s: &str) -> Result<OperationType, AdapterError> {
|
||||
match s {
|
||||
"query" => Ok(OperationType::Query),
|
||||
"mutation" => Ok(OperationType::Mutation),
|
||||
"subscription" => Ok(OperationType::Subscription),
|
||||
"sub" => Ok(OperationType::Sub),
|
||||
"pub" => Ok(OperationType::Pub),
|
||||
other => Err(AdapterError::SchemaParse {
|
||||
message: format!("unknown op_type: {other}"),
|
||||
}),
|
||||
@@ -381,6 +386,36 @@ fn make_streaming_forwarding_handler(
|
||||
})
|
||||
}
|
||||
|
||||
/// Construct a sink forwarding handler for a `FromCall` `Pub` leaf
|
||||
/// (ADR-046): on invocation, calls `CallConnection::publish_with_payload()`
|
||||
/// and forwards the local `PublishStream` to the remote as
|
||||
/// `call.published` events. The remote's single `call.responded` becomes
|
||||
/// the handler's `ResponseEnvelope`. No truncation — the full stream is
|
||||
/// forwarded end-to-end.
|
||||
///
|
||||
/// `forwarded_for` is populated from `context.identity` (ADR-032 §3),
|
||||
/// exactly as the request/response and streaming forwarding handlers
|
||||
/// do — both via `build_forwarded_payload`.
|
||||
fn make_sink_forwarding_handler(
|
||||
connection: Arc<CallConnection>,
|
||||
remote_name: String,
|
||||
) -> SinkHandler {
|
||||
use crate::registry::registration::make_sink_handler;
|
||||
use futures::stream::StreamExt;
|
||||
make_sink_handler(move |input, context, publish_stream| {
|
||||
let connection = Arc::clone(&connection);
|
||||
let remote_name = remote_name.clone();
|
||||
async move {
|
||||
let payload = build_forwarded_payload(&remote_name, input, &context);
|
||||
let chunks: Vec<Value> = publish_stream
|
||||
.filter_map(|item| async move { item.ok() })
|
||||
.collect()
|
||||
.await;
|
||||
connection.publish_with_payload(payload, chunks).await
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the `call.requested` payload for a forwarded call, populating
|
||||
/// `forwarded_for` from the hub's `OperationContext.identity` (ADR-032 §3).
|
||||
/// `forwarded_for` is omitted when `context.identity` is `None` (the hub
|
||||
@@ -810,18 +845,18 @@ mod tests {
|
||||
assert_eq!(bundles[0].spec.name, "worker/exec");
|
||||
}
|
||||
|
||||
// --- ADR-049 §8: streaming forwarding for Subscription ops -------------
|
||||
// --- ADR-021 §8: streaming forwarding for Sub ops ---------------------
|
||||
|
||||
#[test]
|
||||
fn build_bundles_subscription_op_produces_stream_kind() {
|
||||
let conn = CallConnection::new(stub_connection());
|
||||
let discovered = vec![op_summary_typed("events/stream", "subscription", &conn)];
|
||||
let discovered = vec![op_summary_typed("events/stream", "sub", &conn)];
|
||||
let bundles = build_bundles(discovered, &None, &None).expect("bundles");
|
||||
assert_eq!(bundles.len(), 1);
|
||||
assert_eq!(bundles[0].spec.op_type, OperationType::Subscription);
|
||||
assert_eq!(bundles[0].spec.op_type, OperationType::Sub);
|
||||
assert!(
|
||||
matches!(bundles[0].handler, HandlerKind::Stream(_)),
|
||||
"Subscription op must register HandlerKind::Stream"
|
||||
"Sub op must register HandlerKind::Stream"
|
||||
);
|
||||
assert_eq!(bundles[0].provenance, OperationProvenance::FromCall);
|
||||
assert!(bundles[0].composition_authority.is_none());
|
||||
@@ -860,7 +895,7 @@ mod tests {
|
||||
let discovered = vec![
|
||||
op_summary_typed("fs/readFile", "query", &conn),
|
||||
op_summary_typed("fs/writeFile", "mutation", &conn),
|
||||
op_summary_typed("events/stream", "subscription", &conn),
|
||||
op_summary_typed("events/stream", "sub", &conn),
|
||||
];
|
||||
let bundles = build_bundles(discovered, &None, &None).expect("bundles");
|
||||
assert_eq!(bundles.len(), 3);
|
||||
@@ -981,7 +1016,7 @@ mod tests {
|
||||
let reg = HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"events/stream",
|
||||
OperationType::Subscription,
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
|
||||
@@ -242,6 +242,125 @@ impl CallConnection {
|
||||
self.pending.lock().handle_aborted(request_id);
|
||||
}
|
||||
|
||||
/// Publish a stream to a remote `Pub` operation (ADR-046). Sends
|
||||
/// `call.requested` (open), then one `call.published` per chunk in
|
||||
/// `chunks`, then `call.completed` (stream end). Returns the
|
||||
/// responder's single `ResponseEnvelope`.
|
||||
pub async fn publish(
|
||||
&self,
|
||||
operation_id: &str,
|
||||
input: Value,
|
||||
chunks: Vec<Value>,
|
||||
) -> ResponseEnvelope {
|
||||
let payload = serde_json::json!({
|
||||
"operationId": operation_id,
|
||||
"input": input,
|
||||
});
|
||||
self.publish_with_payload(payload, chunks).await
|
||||
}
|
||||
|
||||
/// Publish a stream to a remote `Pub` op with a caller-constructed
|
||||
/// `call.requested` payload. The payload MUST include `operationId`
|
||||
/// and `input`; the caller may add `forwarded_for` (ADR-032) and
|
||||
/// `auth_token` for the hub forwarding path used by `from_call`'s
|
||||
/// sink forwarding handler. Mirrors
|
||||
/// [`subscribe_with_payload`](Self::subscribe_with_payload).
|
||||
pub async fn publish_with_payload(
|
||||
&self,
|
||||
payload: Value,
|
||||
chunks: Vec<Value>,
|
||||
) -> ResponseEnvelope {
|
||||
let request_id = generate_request_id();
|
||||
|
||||
let connection = match &self.connection {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return ResponseEnvelope::error(
|
||||
request_id,
|
||||
CallError::internal("no underlying connection (overlay-only)"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let stream = match connection.open_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
return ResponseEnvelope::error(
|
||||
request_id,
|
||||
CallError::internal(format!("failed to open stream: {err}")),
|
||||
);
|
||||
}
|
||||
};
|
||||
let (recv, send) = tokio::io::split(stream);
|
||||
|
||||
let receiver = {
|
||||
let mut pending = self.pending.lock();
|
||||
pending.register_call(
|
||||
request_id.clone(),
|
||||
Instant::now() + DEFAULT_CALL_TIMEOUT,
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
if let Err(err) = self.write_request(send, &request_id, payload).await {
|
||||
let call_error = CallError::internal(err);
|
||||
self.pending
|
||||
.lock()
|
||||
.handle_error(&request_id, call_error.clone());
|
||||
return ResponseEnvelope::error(request_id, call_error);
|
||||
}
|
||||
|
||||
let write_result = self.write_publish_chunks(&request_id, chunks).await;
|
||||
if let Err(err) = write_result {
|
||||
let call_error = CallError::internal(err);
|
||||
self.pending
|
||||
.lock()
|
||||
.handle_error(&request_id, call_error.clone());
|
||||
return ResponseEnvelope::error(request_id, call_error);
|
||||
}
|
||||
|
||||
let pending = Arc::clone(&self.pending);
|
||||
tokio::spawn(async move {
|
||||
read_stream_until_closed(recv, &pending).await;
|
||||
});
|
||||
|
||||
match receiver.await {
|
||||
Ok(Ok(value)) => ResponseEnvelope::ok(request_id, value),
|
||||
Ok(Err(error)) => ResponseEnvelope::error(request_id, error),
|
||||
Err(_) => ResponseEnvelope::error(request_id, CallError::internal("request cancelled")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_publish_chunks(
|
||||
&self,
|
||||
request_id: &str,
|
||||
chunks: Vec<Value>,
|
||||
) -> Result<(), String> {
|
||||
let connection = self
|
||||
.connection
|
||||
.as_ref()
|
||||
.ok_or_else(|| "no underlying connection (overlay-only)".to_string())?;
|
||||
let stream = connection
|
||||
.open_bi()
|
||||
.await
|
||||
.map_err(|e| format!("failed to open stream: {e}"))?;
|
||||
let (_recv, send) = tokio::io::split(stream);
|
||||
let mut writer = FrameFramedWriter::new(send);
|
||||
for chunk in chunks {
|
||||
let envelope = EventEnvelope::published(request_id, chunk);
|
||||
writer
|
||||
.write_frame(&envelope)
|
||||
.await
|
||||
.map_err(|e| format!("failed to write published frame: {e}"))?;
|
||||
}
|
||||
let completed = EventEnvelope::completed(request_id);
|
||||
writer
|
||||
.write_frame(&completed)
|
||||
.await
|
||||
.map_err(|e| format!("failed to write completed frame: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_request<W>(
|
||||
&self,
|
||||
send: W,
|
||||
@@ -398,7 +517,13 @@ impl OperationEnv for OverlayOperationEnv {
|
||||
HandlerKind::Stream(_) => ResponseEnvelope::error(
|
||||
parent.request_id.clone(),
|
||||
CallError::invalid_operation_type(
|
||||
"OperationEnv::invoke() called on a Subscription op; composition is request/response-only",
|
||||
"OperationEnv::invoke() called on a Sub op; composition is request/response-only",
|
||||
),
|
||||
),
|
||||
HandlerKind::Sink(_) => ResponseEnvelope::error(
|
||||
parent.request_id.clone(),
|
||||
CallError::invalid_operation_type(
|
||||
"OperationEnv::invoke() called on a Pub op; composition is request/response-only",
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -995,7 +1120,7 @@ mod tests {
|
||||
conn.register_imported(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"events/stream",
|
||||
OperationType::Subscription,
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
|
||||
@@ -11,13 +11,17 @@
|
||||
//! See `docs/architecture/` for the spec.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::core::auth::{AuthToken, Identity, IdentityProvider};
|
||||
use crate::core::ownership::OwnershipProvider;
|
||||
use crate::core::types::StreamError;
|
||||
use futures::channel::mpsc;
|
||||
use futures::stream::StreamExt;
|
||||
use futures::SinkExt;
|
||||
use serde_json::Value;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{debug, warn};
|
||||
@@ -31,24 +35,49 @@ use super::wire::{
|
||||
use crate::protocol::adapter::SessionOverlaySource;
|
||||
use crate::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
|
||||
use crate::registry::env::{LocalOperationEnv, OperationEnv, PeerCompositeEnv};
|
||||
use crate::registry::registration::{OperationRegistry, ResponseStream};
|
||||
use crate::registry::spec::OperationType;
|
||||
use crate::registry::registration::{
|
||||
extract_json_pointer, HandlerKind, OperationRegistry, PublishStream, ResponseStream,
|
||||
};
|
||||
use crate::registry::spec::{AccessResult, OperationType, Visibility};
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const SWEEPER_INTERVAL: Duration = Duration::from_secs(10);
|
||||
const PUBLISH_CHANNEL_BUFFER: usize = 64;
|
||||
|
||||
/// Outcome of dispatching a `call.requested` event. The dispatcher branches on
|
||||
/// the registered operation's `op_type` (ADR-049 §6): `Query`/`Mutation` produce
|
||||
/// a single [`ResponseEnvelope`] (`Once`), `Subscription` produces a
|
||||
/// [`ResponseStream`] (`Stream`) that `handle_stream` pumps to the wire.
|
||||
/// the registered operation's `op_type` (ADR-021 §6, ADR-046):
|
||||
/// `Query`/`Mutation` produce a single [`ResponseEnvelope`] (`Once`),
|
||||
/// `Sub` produces a [`ResponseStream`] (`Stream`) that `handle_stream`
|
||||
/// pumps to the wire, `Pub` produces a [`SinkDispatch`] (`Sink`) that
|
||||
/// `handle_stream` feeds from the wire.
|
||||
///
|
||||
/// This enum is the branch point the spec describes ("branches on `op_type` in
|
||||
/// `handle_stream`"): `dispatch` returns it and `handle_stream` matches on it,
|
||||
/// keeping the Once path (one frame, no `call.completed`) and the Stream path
|
||||
/// (each envelope → frame, `call.completed` on natural end) visibly distinct.
|
||||
/// This enum is the branch point the spec describes ("branches on
|
||||
/// `op_type` in `handle_stream`"): `dispatch` returns it and
|
||||
/// `handle_stream` matches on it, keeping the Once path (one frame, no
|
||||
/// `call.completed`), the Stream path (each envelope → frame,
|
||||
/// `call.completed` on natural end), and the Sink path (feed
|
||||
/// `call.published` chunks → one `call.responded`) visibly distinct.
|
||||
pub enum DispatchResult {
|
||||
Once(ResponseEnvelope),
|
||||
Stream(ResponseStream),
|
||||
Sink(SinkDispatch),
|
||||
}
|
||||
|
||||
/// The sink dispatch result for a `Pub` operation (ADR-046). Carries:
|
||||
///
|
||||
/// - `handler`: the future returned by `invoke_sink()` — the
|
||||
/// `SinkHandler` consuming the `PublishStream` and producing a single
|
||||
/// `ResponseEnvelope`.
|
||||
/// - `chunk_tx`: the mpsc sender for feeding `call.published` chunks from
|
||||
/// the wire into the handler's `PublishStream`.
|
||||
///
|
||||
/// `handle_stream` reads `call.published` events from the wire, sends
|
||||
/// each chunk's `input` into `chunk_tx`, and when the stream ends
|
||||
/// (`call.completed` or wire read end), drops `chunk_tx` and awaits
|
||||
/// `handler` to get the single `ResponseEnvelope` to write to the wire.
|
||||
pub struct SinkDispatch {
|
||||
pub handler: Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>,
|
||||
pub chunk_tx: mpsc::Sender<Result<Value, CallError>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DispatchResult {
|
||||
@@ -58,6 +87,7 @@ impl std::fmt::Debug for DispatchResult {
|
||||
DispatchResult::Stream(_) => {
|
||||
f.debug_tuple("Stream").field(&"<ResponseStream>").finish()
|
||||
}
|
||||
DispatchResult::Sink(_) => f.debug_tuple("Sink").field(&"<SinkDispatch>").finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,24 +237,29 @@ impl Dispatcher {
|
||||
ResponseEnvelope::error(
|
||||
String::new(),
|
||||
CallError::internal(
|
||||
"dispatch_requested called on a Subscription op; use the streaming path",
|
||||
"dispatch_requested called on a Sub op; use the streaming path",
|
||||
),
|
||||
)
|
||||
}),
|
||||
DispatchResult::Sink(_) => ResponseEnvelope::error(
|
||||
String::new(),
|
||||
CallError::internal("dispatch_requested called on a Pub op; use the sink path"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch a `call.requested` event, branching on the registered
|
||||
/// operation's `op_type` (ADR-049 §6). `Query`/`Mutation` → `invoke()` →
|
||||
/// [`DispatchResult::Once`]; `Subscription` → `invoke_streaming()` →
|
||||
/// [`DispatchResult::Stream`]. Unknown ops and ACL failures resolve via
|
||||
/// the registry's own envelope/error paths (Once for `invoke`, a single
|
||||
/// error envelope for `invoke_streaming`).
|
||||
/// operation's `op_type` (ADR-021 §6, ADR-046). `Query`/`Mutation` →
|
||||
/// `invoke()` → [`DispatchResult::Once`]; `Sub` →
|
||||
/// `invoke_streaming()` → [`DispatchResult::Stream`]; `Pub` →
|
||||
/// `invoke_sink()` → [`DispatchResult::Sink`]. Unknown ops and ACL
|
||||
/// failures resolve via the registry's own envelope/error paths.
|
||||
///
|
||||
/// For the streaming branch the root context's deadline is cleared
|
||||
/// (`deadline: None`): subscriptions are long-running and unbounded — the
|
||||
/// 30s request/response deadline does not apply (ADR-049 §6, call-protocol
|
||||
/// Timeouts). The Once branch keeps the deadline from `build_root_context`.
|
||||
/// For the `Sub` and `Pub` branches the root context's deadline is
|
||||
/// cleared (`deadline: None`): streaming and publish operations are
|
||||
/// long-running and unbounded — the 30s request/response deadline
|
||||
/// does not apply. The Once branch keeps the deadline from
|
||||
/// `build_root_context`.
|
||||
pub async fn dispatch(
|
||||
&self,
|
||||
connection: &Arc<CallConnection>,
|
||||
@@ -245,11 +280,11 @@ impl Dispatcher {
|
||||
|
||||
let input = payload.get("input").cloned().unwrap_or(Value::Null);
|
||||
|
||||
let is_subscription = self
|
||||
let op_type = self
|
||||
.registry
|
||||
.registration(&operation_name)
|
||||
.map(|r| r.spec.op_type == OperationType::Subscription)
|
||||
.unwrap_or(false);
|
||||
.map(|r| r.spec.op_type)
|
||||
.unwrap_or(OperationType::Query);
|
||||
|
||||
let mut context = self.build_root_context(
|
||||
request_id.clone(),
|
||||
@@ -259,15 +294,87 @@ impl Dispatcher {
|
||||
connection,
|
||||
);
|
||||
|
||||
if is_subscription {
|
||||
context.deadline = None;
|
||||
let stream = self
|
||||
.registry
|
||||
.invoke_streaming(&operation_name, input, context);
|
||||
DispatchResult::Stream(stream)
|
||||
} else {
|
||||
let envelope = self.registry.invoke(&operation_name, input, context).await;
|
||||
DispatchResult::Once(envelope)
|
||||
match op_type {
|
||||
OperationType::Query | OperationType::Mutation => {
|
||||
let envelope = self.registry.invoke(&operation_name, input, context).await;
|
||||
DispatchResult::Once(envelope)
|
||||
}
|
||||
OperationType::Sub => {
|
||||
context.deadline = None;
|
||||
let stream = self
|
||||
.registry
|
||||
.invoke_streaming(&operation_name, input, context);
|
||||
DispatchResult::Stream(stream)
|
||||
}
|
||||
OperationType::Pub => {
|
||||
context.deadline = None;
|
||||
let registration = match self.registry.registration(&operation_name) {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return DispatchResult::Once(ResponseEnvelope::not_found(
|
||||
request_id.clone(),
|
||||
&operation_name,
|
||||
));
|
||||
}
|
||||
};
|
||||
if registration.spec.visibility == Visibility::Internal && !context.internal {
|
||||
return DispatchResult::Once(ResponseEnvelope::not_found(
|
||||
request_id.clone(),
|
||||
&operation_name,
|
||||
));
|
||||
}
|
||||
let acl = ®istration.spec.access_control;
|
||||
let identity = if context.internal {
|
||||
context
|
||||
.handler_identity
|
||||
.as_ref()
|
||||
.and_then(|ca| ca.as_identity())
|
||||
} else {
|
||||
context.identity.clone()
|
||||
};
|
||||
let resource_id = registration
|
||||
.spec
|
||||
.resource_id_path
|
||||
.as_ref()
|
||||
.and_then(|path| extract_json_pointer(&input, path));
|
||||
if let AccessResult::Forbidden(message) = acl.check(
|
||||
identity.as_ref(),
|
||||
resource_id.as_deref(),
|
||||
context.ownership.as_deref(),
|
||||
) {
|
||||
return DispatchResult::Once(ResponseEnvelope::forbidden(
|
||||
request_id.clone(),
|
||||
message,
|
||||
));
|
||||
}
|
||||
let sink_handler = match ®istration.handler {
|
||||
HandlerKind::Sink(h) => Arc::clone(h),
|
||||
HandlerKind::Once(_) => {
|
||||
return DispatchResult::Once(ResponseEnvelope::error(
|
||||
request_id.clone(),
|
||||
CallError::invalid_operation_type(
|
||||
"invoke_sink() called on a Query/Mutation op; use invoke()",
|
||||
),
|
||||
));
|
||||
}
|
||||
HandlerKind::Stream(_) => {
|
||||
return DispatchResult::Once(ResponseEnvelope::error(
|
||||
request_id.clone(),
|
||||
CallError::invalid_operation_type(
|
||||
"invoke_sink() called on a Sub op; use invoke_streaming()",
|
||||
),
|
||||
));
|
||||
}
|
||||
};
|
||||
let (chunk_tx, chunk_rx) =
|
||||
mpsc::channel::<Result<Value, CallError>>(PUBLISH_CHANNEL_BUFFER);
|
||||
let publish_stream: PublishStream = Box::pin(chunk_rx);
|
||||
let handler = (sink_handler)(input, context, publish_stream);
|
||||
DispatchResult::Sink(SinkDispatch {
|
||||
handler: Box::pin(handler),
|
||||
chunk_tx,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +430,10 @@ impl Dispatcher {
|
||||
DispatchResult::Stream(stream) => {
|
||||
self.pump_stream(&mut writer, &request_id, stream).await;
|
||||
}
|
||||
DispatchResult::Sink(sink) => {
|
||||
self.pump_sink(&mut reader, &mut writer, request_id, sink)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
EVENT_ABORTED => {
|
||||
@@ -373,6 +484,90 @@ impl Dispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pump a `Pub` operation's sink to the wire (ADR-046): feed
|
||||
/// `call.published` chunks from the wire into the `SinkDispatch`'s
|
||||
/// `chunk_tx`, and when the publish stream ends (`call.completed`
|
||||
/// initiator→responder, or wire read end), drop `chunk_tx` and await
|
||||
/// the handler future to get the single `ResponseEnvelope`, which
|
||||
/// is written to the wire as `call.responded` (or `call.error`).
|
||||
///
|
||||
/// The handler runs concurrently — it consumes the
|
||||
/// `PublishStream` while we feed it. `call.aborted` for this request
|
||||
/// ID (handled by `handle_abort` on another stream) or connection
|
||||
/// close cancels the task and drops the handler future, releasing
|
||||
/// the handler's resources via `Drop` (ADR-020).
|
||||
pub(crate) async fn pump_sink<R, W>(
|
||||
&self,
|
||||
reader: &mut super::wire::FrameFramedReader<R>,
|
||||
writer: &mut super::wire::FrameFramedWriter<W>,
|
||||
request_id: String,
|
||||
sink: SinkDispatch,
|
||||
) where
|
||||
R: tokio::io::AsyncRead + Unpin,
|
||||
W: tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let SinkDispatch {
|
||||
handler,
|
||||
mut chunk_tx,
|
||||
} = sink;
|
||||
|
||||
let feed_fut = async {
|
||||
loop {
|
||||
match reader.read_frame().await {
|
||||
Ok(envelope) => {
|
||||
if envelope.id != request_id {
|
||||
debug!(
|
||||
event_type = %envelope.r#type,
|
||||
id = %envelope.id,
|
||||
"ignoring event with mismatched id during sink pump"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match envelope.r#type.as_str() {
|
||||
"call.published" => {
|
||||
let chunk = envelope
|
||||
.payload
|
||||
.get("input")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
if chunk_tx.send(Ok(chunk)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
"call.completed" => break,
|
||||
"call.aborted" => {
|
||||
let _ = chunk_tx
|
||||
.send(Err(CallError::internal("publish aborted by initiator")))
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
debug!(
|
||||
event_type = %envelope.r#type,
|
||||
"ignoring non-published event during sink pump"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(super::wire::FrameError::ConnectionClosed) => break,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "frame read error during sink pump; ending stream");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(chunk_tx);
|
||||
};
|
||||
|
||||
let (feed_result, response) = tokio::join!(feed_fut, handler);
|
||||
let _: () = feed_result;
|
||||
|
||||
let event: EventEnvelope = response.into();
|
||||
if let Err(err) = writer.write_frame(&event).await {
|
||||
warn!(error = %err, "failed to write sink response frame");
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the shared dispatch loop over an established `CallConnection`:
|
||||
/// spawn the pending-entry sweeper, accept bidirectional streams until the
|
||||
/// connection closes, dispatch each stream via `handle_stream`, and fail
|
||||
@@ -985,7 +1180,7 @@ mod tests {
|
||||
fn subscription_spec(name: &str, acl: AccessControl) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Subscription,
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
@@ -1359,4 +1554,169 @@ mod tests {
|
||||
"stream future dropped → Drop guard released handler resources"
|
||||
);
|
||||
}
|
||||
|
||||
// --- ADR-046: Pub/sink dispatch branch --------------------------------
|
||||
|
||||
fn pub_spec(name: &str, acl: AccessControl) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
acl,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn registry_with_pub(
|
||||
name: &str,
|
||||
handler: crate::registry::registration::SinkHandler,
|
||||
) -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
pub_spec(name, AccessControl::default()),
|
||||
HandlerKind::Sink(handler),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
fn counting_sink_handler() -> crate::registry::registration::SinkHandler {
|
||||
use crate::registry::registration::make_sink_handler;
|
||||
use futures::stream::StreamExt;
|
||||
make_sink_handler(|_input, ctx, mut stream| async move {
|
||||
let mut count = 0u32;
|
||||
while let Some(item) = stream.next().await {
|
||||
match item {
|
||||
Ok(_) => count += 1,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({ "count": count }))
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_pub_returns_sink_result() {
|
||||
let registry = registry_with_pub("fs/upload", counting_sink_handler());
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||
let dp = Dispatcher::new(registry, provider);
|
||||
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"operationId": "/fs/upload",
|
||||
"input": { "path": "/x" },
|
||||
});
|
||||
match dp.dispatch(&conn, "pub-1".to_string(), payload).await {
|
||||
DispatchResult::Sink(sink) => {
|
||||
use futures::SinkExt;
|
||||
let mut tx = sink.chunk_tx;
|
||||
tx.send(Ok(serde_json::json!({"chunk": 1}))).await.unwrap();
|
||||
tx.send(Ok(serde_json::json!({"chunk": 2}))).await.unwrap();
|
||||
drop(tx);
|
||||
let response = sink.handler.await;
|
||||
let out = response.result.expect("ok");
|
||||
assert_eq!(out["count"], serde_json::json!(2));
|
||||
}
|
||||
other => panic!("expected Sink, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_pub_clears_deadline_to_none() {
|
||||
let registry = registry_with_pub("fs/upload", counting_sink_handler());
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||
let dp = Dispatcher::new(registry, provider);
|
||||
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"operationId": "/fs/upload",
|
||||
"input": {},
|
||||
});
|
||||
match dp.dispatch(&conn, "pub-dl".to_string(), payload).await {
|
||||
DispatchResult::Sink(sink) => {
|
||||
drop(sink.chunk_tx);
|
||||
let _ = sink.handler.await;
|
||||
}
|
||||
other => panic!("expected Sink, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_stream_pub_feeds_published_chunks_and_returns_response() {
|
||||
let registry = registry_with_pub("fs/upload", counting_sink_handler());
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||
let dp = Dispatcher::new(registry, provider);
|
||||
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||
|
||||
let request = EventEnvelope::requested(
|
||||
"pub-pump-1",
|
||||
serde_json::json!({
|
||||
"operationId": "/fs/upload",
|
||||
"input": { "path": "/x" },
|
||||
}),
|
||||
);
|
||||
let published1 = EventEnvelope::published("pub-pump-1", serde_json::json!({"chunk": 1}));
|
||||
let published2 = EventEnvelope::published("pub-pump-1", serde_json::json!({"chunk": 2}));
|
||||
let completed = EventEnvelope::completed("pub-pump-1");
|
||||
|
||||
let mut frame_buf = Vec::new();
|
||||
frame_buf.extend_from_slice(&encode_frame(&request));
|
||||
frame_buf.extend_from_slice(&encode_frame(&published1));
|
||||
frame_buf.extend_from_slice(&encode_frame(&published2));
|
||||
frame_buf.extend_from_slice(&encode_frame(&completed));
|
||||
|
||||
let recv = tokio::io::BufReader::new(std::io::Cursor::new(frame_buf));
|
||||
let (send, mut sink) = tokio::io::duplex(8 * 1024);
|
||||
let stream = crate::core::types::BiStream::from_joined(recv, send);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
dp.handle_stream(conn, stream).await;
|
||||
});
|
||||
|
||||
let frames = read_all_frames(&mut sink).await;
|
||||
handle.await.unwrap();
|
||||
assert_eq!(frames.len(), 1, "pub: one response frame, no completed");
|
||||
assert_eq!(frames[0].r#type, EVENT_RESPONDED);
|
||||
assert_eq!(frames[0].id, "pub-pump-1");
|
||||
let output = frames[0].payload.get("output").expect("output field");
|
||||
assert_eq!(output["count"], serde_json::json!(2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_stream_pub_unknown_op_returns_error() {
|
||||
let registry = Arc::new(OperationRegistry::new());
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||
let dp = Dispatcher::new(registry, provider);
|
||||
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||
|
||||
let request = EventEnvelope::requested(
|
||||
"pub-missing-1",
|
||||
serde_json::json!({
|
||||
"operationId": "/no/such/pub",
|
||||
"input": {},
|
||||
}),
|
||||
);
|
||||
let recv = tokio::io::BufReader::new(std::io::Cursor::new(encode_frame(&request)));
|
||||
let (send, mut sink) = tokio::io::duplex(8 * 1024);
|
||||
let stream = crate::core::types::BiStream::from_joined(recv, send);
|
||||
|
||||
dp.handle_stream(conn, stream).await;
|
||||
|
||||
let frames = read_all_frames(&mut sink).await;
|
||||
assert_eq!(frames.len(), 1, "unknown pub op: single error");
|
||||
assert_eq!(frames[0].r#type, EVENT_ERROR);
|
||||
assert_eq!(frames[0].id, "pub-missing-1");
|
||||
assert_eq!(
|
||||
frames[0].payload.get("code"),
|
||||
Some(&Value::String("NOT_FOUND".into()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ pub const EVENT_RESPONDED: &str = "call.responded";
|
||||
pub const EVENT_COMPLETED: &str = "call.completed";
|
||||
pub const EVENT_ABORTED: &str = "call.aborted";
|
||||
pub const EVENT_ERROR: &str = "call.error";
|
||||
pub const EVENT_PUBLISHED: &str = "call.published";
|
||||
|
||||
const LENGTH_PREFIX_BYTES: usize = 4;
|
||||
const MAX_FRAME_SIZE: u32 = 64 * 1024 * 1024;
|
||||
@@ -47,6 +48,13 @@ impl EventEnvelope {
|
||||
Self::new(EVENT_COMPLETED, id, serde_json::json!({}))
|
||||
}
|
||||
|
||||
/// A `call.published` event — one chunk of the initiator's data stream
|
||||
/// for a `Pub` operation (ADR-046). The `payload` is
|
||||
/// `{ "input": <Value> }` — the published chunk.
|
||||
pub fn published(id: impl Into<String>, chunk: Value) -> Self {
|
||||
Self::new(EVENT_PUBLISHED, id, serde_json::json!({ "input": chunk }))
|
||||
}
|
||||
|
||||
pub fn aborted(id: impl Into<String>) -> Self {
|
||||
Self::new(EVENT_ABORTED, id, serde_json::json!({}))
|
||||
}
|
||||
@@ -458,6 +466,31 @@ mod tests {
|
||||
assert_eq!(event.payload, serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_envelope_published_wraps_input() {
|
||||
let event = EventEnvelope::published("pub-1", Value::String("chunk-data".into()));
|
||||
assert_eq!(event.r#type, EVENT_PUBLISHED);
|
||||
assert_eq!(event.id, "pub-1");
|
||||
assert_eq!(
|
||||
event.payload.get("input"),
|
||||
Some(&Value::String("chunk-data".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn round_trip_published_envelope() {
|
||||
let (client, server) = duplex(8 * 1024);
|
||||
let envelope = EventEnvelope::published("pub-rt", serde_json::json!({"n": 1}));
|
||||
|
||||
let mut writer = FrameFramedWriter::new(client);
|
||||
writer.write_frame(&envelope).await.unwrap();
|
||||
drop(writer);
|
||||
|
||||
let mut reader = FrameFramedReader::new(server);
|
||||
let read = reader.read_frame().await.unwrap();
|
||||
assert_eq!(read, envelope);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_envelope_responded_wraps_output() {
|
||||
let event = EventEnvelope::responded("req-1", Value::Number(42.into()));
|
||||
|
||||
@@ -29,7 +29,7 @@ pub fn services_list_spec() -> OperationSpec {
|
||||
"namespace": { "type": "string" },
|
||||
"op_type": {
|
||||
"type": "string",
|
||||
"enum": ["query", "mutation", "subscription"]
|
||||
"enum": ["query", "mutation", "sub", "pub"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ pub fn services_list_peers_spec() -> OperationSpec {
|
||||
"namespace": { "type": "string" },
|
||||
"op_type": {
|
||||
"type": "string",
|
||||
"enum": ["query", "mutation", "subscription"]
|
||||
"enum": ["query", "mutation", "sub", "pub"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ fn operation_spec_schema() -> Value {
|
||||
"namespace": { "type": "string" },
|
||||
"op_type": {
|
||||
"type": "string",
|
||||
"enum": ["query", "mutation", "subscription"]
|
||||
"enum": ["query", "mutation", "sub", "pub"]
|
||||
},
|
||||
"visibility": {
|
||||
"type": "string",
|
||||
@@ -160,7 +160,8 @@ fn op_type_str(op_type: OperationType) -> &'static str {
|
||||
match op_type {
|
||||
OperationType::Query => "query",
|
||||
OperationType::Mutation => "mutation",
|
||||
OperationType::Subscription => "subscription",
|
||||
OperationType::Sub => "sub",
|
||||
OperationType::Pub => "pub",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,7 +543,7 @@ mod tests {
|
||||
.register(HandlerRegistration::new(
|
||||
OperationSpec::new(
|
||||
"events/subscribe",
|
||||
OperationType::Subscription,
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
json!({}),
|
||||
json!({}),
|
||||
@@ -752,7 +753,8 @@ mod tests {
|
||||
fn op_type_str_matches_wire_enum() {
|
||||
assert_eq!(op_type_str(OperationType::Query), "query");
|
||||
assert_eq!(op_type_str(OperationType::Mutation), "mutation");
|
||||
assert_eq!(op_type_str(OperationType::Subscription), "subscription");
|
||||
assert_eq!(op_type_str(OperationType::Sub), "sub");
|
||||
assert_eq!(op_type_str(OperationType::Pub), "pub");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -23,12 +23,36 @@ pub type StreamingHandler = Arc<
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
/// Sink handler — `Pub` operations (ADR-046). Receives the initiator's
|
||||
/// data stream (each `call.published` chunk's `input` as one `Value`
|
||||
/// item) and returns a single `ResponseEnvelope` (the result of
|
||||
/// consuming the stream). An `Err` item in the stream (an initiator-side
|
||||
/// error) terminates the stream early; the handler may produce its
|
||||
/// result from the partial input or propagate the error.
|
||||
pub type SinkHandler = Arc<
|
||||
dyn Fn(
|
||||
Value,
|
||||
OperationContext,
|
||||
Pin<Box<dyn Stream<Item = Result<Value, CallError>> + Send>>,
|
||||
) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
pub type ResponseStream = Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>;
|
||||
|
||||
/// The publish stream shape consumed by a `SinkHandler`. Each item is
|
||||
/// one published chunk: `Ok(value)` for a `call.published` event,
|
||||
/// `Err(call_error)` for an initiator-side `call.error`. Stream end
|
||||
/// (the initiator's `call.completed` or write-half close) yields
|
||||
/// `None` — the `SinkHandler` sees a terminated stream.
|
||||
pub type PublishStream = Pin<Box<dyn Stream<Item = Result<Value, CallError>> + Send>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum HandlerKind {
|
||||
Once(Handler),
|
||||
Stream(StreamingHandler),
|
||||
Sink(SinkHandler),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -84,11 +108,13 @@ impl OperationRegistry {
|
||||
pub fn register(&mut self, registration: HandlerRegistration) -> Result<(), String> {
|
||||
let expected = match registration.spec.op_type {
|
||||
OperationType::Query | OperationType::Mutation => "Once",
|
||||
OperationType::Subscription => "Stream",
|
||||
OperationType::Sub => "Stream",
|
||||
OperationType::Pub => "Sink",
|
||||
};
|
||||
let actual = match registration.handler {
|
||||
HandlerKind::Once(_) => "Once",
|
||||
HandlerKind::Stream(_) => "Stream",
|
||||
HandlerKind::Sink(_) => "Sink",
|
||||
};
|
||||
if expected != actual {
|
||||
return Err(format!(
|
||||
@@ -161,9 +187,13 @@ impl OperationRegistry {
|
||||
HandlerKind::Stream(_) => ResponseEnvelope::error(
|
||||
request_id,
|
||||
CallError::invalid_operation_type(
|
||||
"invoke() called on a Subscription op; use invoke_streaming()",
|
||||
"invoke() called on a Sub op; use invoke_streaming()",
|
||||
),
|
||||
),
|
||||
HandlerKind::Sink(_) => ResponseEnvelope::error(
|
||||
request_id,
|
||||
CallError::invalid_operation_type("invoke() called on a Pub op; use invoke_sink()"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,10 +259,90 @@ impl OperationRegistry {
|
||||
)
|
||||
}));
|
||||
}
|
||||
HandlerKind::Sink(_) => {
|
||||
return Box::pin(stream::once(async move {
|
||||
ResponseEnvelope::error(
|
||||
request_id,
|
||||
CallError::invalid_operation_type(
|
||||
"invoke_streaming() called on a Pub op; use invoke_sink()",
|
||||
),
|
||||
)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
streaming_handler(input, context)
|
||||
}
|
||||
|
||||
/// Dispatch a `Pub` operation (ADR-046). The `publish_stream` is the
|
||||
/// initiator's data stream — each `call.published` chunk's `input` as
|
||||
/// one `Ok(Value)` item, or an initiator-side error as
|
||||
/// `Err(CallError)`. Pre-handler errors (not-found, forbidden,
|
||||
/// `INVALID_OPERATION_TYPE` for a non-Pub op) return a single error
|
||||
/// `ResponseEnvelope`.
|
||||
pub async fn invoke_sink(
|
||||
&self,
|
||||
name: &str,
|
||||
input: Value,
|
||||
publish_stream: PublishStream,
|
||||
context: OperationContext,
|
||||
) -> ResponseEnvelope {
|
||||
let request_id = context.request_id.clone();
|
||||
let registration = match self.operations.get(name) {
|
||||
Some(r) => r,
|
||||
None => return ResponseEnvelope::not_found(request_id, name),
|
||||
};
|
||||
|
||||
if registration.spec.visibility == Visibility::Internal && !context.internal {
|
||||
return ResponseEnvelope::not_found(request_id, name);
|
||||
}
|
||||
|
||||
let acl = ®istration.spec.access_control;
|
||||
let identity = if context.internal {
|
||||
context
|
||||
.handler_identity
|
||||
.as_ref()
|
||||
.and_then(|ca| ca.as_identity())
|
||||
} else {
|
||||
context.identity.clone()
|
||||
};
|
||||
|
||||
let resource_id = registration
|
||||
.spec
|
||||
.resource_id_path
|
||||
.as_ref()
|
||||
.and_then(|path| extract_json_pointer(&input, path));
|
||||
|
||||
if let AccessResult::Forbidden(message) = acl.check(
|
||||
identity.as_ref(),
|
||||
resource_id.as_deref(),
|
||||
context.ownership.as_deref(),
|
||||
) {
|
||||
return ResponseEnvelope::forbidden(request_id, message);
|
||||
}
|
||||
|
||||
let sink_handler = match ®istration.handler {
|
||||
HandlerKind::Sink(h) => Arc::clone(h),
|
||||
HandlerKind::Once(_) => {
|
||||
return ResponseEnvelope::error(
|
||||
request_id,
|
||||
CallError::invalid_operation_type(
|
||||
"invoke_sink() called on a Query/Mutation op; use invoke()",
|
||||
),
|
||||
);
|
||||
}
|
||||
HandlerKind::Stream(_) => {
|
||||
return ResponseEnvelope::error(
|
||||
request_id,
|
||||
CallError::invalid_operation_type(
|
||||
"invoke_sink() called on a Sub op; use invoke_streaming()",
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
(sink_handler)(input, context, publish_stream).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OperationRegistry {
|
||||
@@ -261,20 +371,42 @@ impl OperationRegistryBuilder {
|
||||
fn wrap_once(spec: &OperationSpec, handler: Handler) -> Result<HandlerKind, String> {
|
||||
match spec.op_type {
|
||||
OperationType::Query | OperationType::Mutation => Ok(HandlerKind::Once(handler)),
|
||||
OperationType::Subscription => Err(format!(
|
||||
OperationType::Sub => Err(format!(
|
||||
"handler kind mismatch: {:?} requires HandlerKind::Stream (got Handler)",
|
||||
spec.op_type
|
||||
)),
|
||||
OperationType::Pub => Err(format!(
|
||||
"handler kind mismatch: {:?} requires HandlerKind::Sink (got Handler)",
|
||||
spec.op_type
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_stream(spec: &OperationSpec, handler: StreamingHandler) -> Result<HandlerKind, String> {
|
||||
match spec.op_type {
|
||||
OperationType::Subscription => Ok(HandlerKind::Stream(handler)),
|
||||
OperationType::Sub => Ok(HandlerKind::Stream(handler)),
|
||||
OperationType::Query | OperationType::Mutation => Err(format!(
|
||||
"handler kind mismatch: {:?} requires HandlerKind::Once (got StreamingHandler)",
|
||||
spec.op_type
|
||||
)),
|
||||
OperationType::Pub => Err(format!(
|
||||
"handler kind mismatch: {:?} requires HandlerKind::Sink (got StreamingHandler)",
|
||||
spec.op_type
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_sink(spec: &OperationSpec, handler: SinkHandler) -> Result<HandlerKind, String> {
|
||||
match spec.op_type {
|
||||
OperationType::Pub => Ok(HandlerKind::Sink(handler)),
|
||||
OperationType::Query | OperationType::Mutation => Err(format!(
|
||||
"handler kind mismatch: {:?} requires HandlerKind::Once (got SinkHandler)",
|
||||
spec.op_type
|
||||
)),
|
||||
OperationType::Sub => Err(format!(
|
||||
"handler kind mismatch: {:?} requires HandlerKind::Stream (got SinkHandler)",
|
||||
spec.op_type
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +504,53 @@ impl OperationRegistryBuilder {
|
||||
self.store(registration)
|
||||
}
|
||||
|
||||
pub fn with_local_sink(
|
||||
self,
|
||||
spec: OperationSpec,
|
||||
handler: SinkHandler,
|
||||
composition_authority: Option<CompositionAuthority>,
|
||||
scoped_env: Option<ScopedPeerEnv>,
|
||||
capabilities: Capabilities,
|
||||
) -> Result<Self, String> {
|
||||
let kind = Self::wrap_sink(&spec, handler)?;
|
||||
let registration = HandlerRegistration::new(
|
||||
spec,
|
||||
kind,
|
||||
OperationProvenance::Local,
|
||||
composition_authority,
|
||||
scoped_env,
|
||||
capabilities,
|
||||
);
|
||||
self.store(registration)
|
||||
}
|
||||
|
||||
pub fn with_leaf_sink(
|
||||
self,
|
||||
spec: OperationSpec,
|
||||
handler: SinkHandler,
|
||||
capabilities: Capabilities,
|
||||
) -> Result<Self, String> {
|
||||
self.with_leaf_sink_provenance(
|
||||
spec,
|
||||
handler,
|
||||
OperationProvenance::FromOpenAPI,
|
||||
capabilities,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_leaf_sink_provenance(
|
||||
self,
|
||||
spec: OperationSpec,
|
||||
handler: SinkHandler,
|
||||
provenance: OperationProvenance,
|
||||
capabilities: Capabilities,
|
||||
) -> Result<Self, String> {
|
||||
let kind = Self::wrap_sink(&spec, handler)?;
|
||||
let registration =
|
||||
HandlerRegistration::new(spec, kind, provenance, None, None, capabilities);
|
||||
self.store(registration)
|
||||
}
|
||||
|
||||
pub fn with(self, registration: HandlerRegistration) -> Result<Self, String> {
|
||||
self.store(registration)
|
||||
}
|
||||
@@ -405,6 +584,14 @@ where
|
||||
Arc::new(move |input, context| Box::pin(f(input, context)))
|
||||
}
|
||||
|
||||
pub fn make_sink_handler<F, Fut>(f: F) -> SinkHandler
|
||||
where
|
||||
F: Fn(Value, OperationContext, PublishStream) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = ResponseEnvelope> + Send + 'static,
|
||||
{
|
||||
Arc::new(move |input, context, stream| Box::pin(f(input, context, stream)))
|
||||
}
|
||||
|
||||
/// Extract a string value from `input` at a JSON-pointer-ish path described
|
||||
/// by `$.field` or `$.field/sub` (a leading `$` followed by a slash-separated
|
||||
/// pointer, the same shape `serde_json::Value::pointer` expects after the
|
||||
@@ -1021,7 +1208,7 @@ mod tests {
|
||||
fn subscription_spec(name: &str) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Subscription,
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
@@ -1093,7 +1280,7 @@ mod tests {
|
||||
));
|
||||
match result {
|
||||
Err(msg) => assert!(
|
||||
msg.contains("Subscription")
|
||||
msg.contains("Sub")
|
||||
&& msg.contains("HandlerKind::Stream")
|
||||
&& msg.contains("HandlerKind::Once"),
|
||||
"unexpected message: {msg}"
|
||||
@@ -1308,7 +1495,7 @@ mod tests {
|
||||
fn subscription_spec_with_acl(acl: AccessControl) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
"events/stream",
|
||||
OperationType::Subscription,
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
@@ -1321,7 +1508,7 @@ mod tests {
|
||||
fn internal_subscription_spec(acl: AccessControl) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
"events/stream",
|
||||
OperationType::Subscription,
|
||||
OperationType::Sub,
|
||||
Visibility::Internal,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
@@ -1631,4 +1818,454 @@ mod tests {
|
||||
"no provider wired → static Identity.resources fallback allows"
|
||||
);
|
||||
}
|
||||
|
||||
// --- ADR-046: Pub/SinkHandler/invoke_sink tests ------------------------
|
||||
|
||||
fn pub_spec(name: &str, acl: AccessControl) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Pub,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
acl,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn internal_pub_spec(acl: AccessControl) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
"fs/upload",
|
||||
OperationType::Pub,
|
||||
Visibility::Internal,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
acl,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn collect_sink_handler() -> SinkHandler {
|
||||
use futures::stream::StreamExt;
|
||||
make_sink_handler(|_input, ctx, mut stream| async move {
|
||||
let mut count = 0u32;
|
||||
let mut last = Value::Null;
|
||||
while let Some(item) = stream.next().await {
|
||||
match item {
|
||||
Ok(v) => {
|
||||
count += 1;
|
||||
last = v;
|
||||
}
|
||||
Err(e) => {
|
||||
return ResponseEnvelope::error(
|
||||
ctx.request_id,
|
||||
CallError::internal(format!("publish error: {e:?}")),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponseEnvelope::ok(
|
||||
ctx.request_id,
|
||||
serde_json::json!({ "count": count, "last": last }),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn make_publish_stream(items: Vec<Value>) -> PublishStream {
|
||||
Box::pin(futures::stream::iter(
|
||||
items.into_iter().map(Ok::<_, CallError>),
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_sink_pub_op_collects_stream_and_returns_result() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
pub_spec("fs/upload", AccessControl::default()),
|
||||
HandlerKind::Sink(collect_sink_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context("req-pub-1", None, None, false, ScopedPeerEnv::empty());
|
||||
let stream = make_publish_stream(vec![
|
||||
serde_json::json!({"chunk": 1}),
|
||||
serde_json::json!({"chunk": 2}),
|
||||
serde_json::json!({"chunk": 3}),
|
||||
]);
|
||||
let response = registry
|
||||
.invoke_sink("fs/upload", serde_json::json!({"path": "/x"}), stream, ctx)
|
||||
.await;
|
||||
let out = response.result.expect("ok");
|
||||
assert_eq!(out["count"], serde_json::json!(3));
|
||||
assert_eq!(out["last"], serde_json::json!({"chunk": 3}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_sink_empty_stream_returns_zero_count() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
pub_spec("fs/upload", AccessControl::default()),
|
||||
HandlerKind::Sink(collect_sink_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context("req-pub-2", None, None, false, ScopedPeerEnv::empty());
|
||||
let stream = make_publish_stream(vec![]);
|
||||
let response = registry
|
||||
.invoke_sink("fs/upload", serde_json::json!({}), stream, ctx)
|
||||
.await;
|
||||
let out = response.result.expect("ok");
|
||||
assert_eq!(out["count"], serde_json::json!(0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_sink_unknown_op_returns_not_found() {
|
||||
let registry = OperationRegistry::new();
|
||||
let ctx = root_context("req-pub-3", None, None, false, ScopedPeerEnv::empty());
|
||||
let stream = make_publish_stream(vec![]);
|
||||
let response = registry
|
||||
.invoke_sink("missing", serde_json::json!({}), stream, ctx)
|
||||
.await;
|
||||
match response.result {
|
||||
Err(e) => assert_eq!(e.code, "NOT_FOUND"),
|
||||
other => panic!("expected NOT_FOUND, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_sink_internal_op_from_external_returns_not_found() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
internal_pub_spec(AccessControl::default()),
|
||||
HandlerKind::Sink(collect_sink_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context("req-pub-4", None, None, false, ScopedPeerEnv::empty());
|
||||
let stream = make_publish_stream(vec![]);
|
||||
let response = registry
|
||||
.invoke_sink("fs/upload", serde_json::json!({}), stream, ctx)
|
||||
.await;
|
||||
match response.result {
|
||||
Err(e) => assert_eq!(e.code, "NOT_FOUND"),
|
||||
other => panic!("expected NOT_FOUND, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_sink_acl_denied_returns_forbidden() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
pub_spec(
|
||||
"fs/upload",
|
||||
AccessControl {
|
||||
required_scopes: vec!["fs:write".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
HandlerKind::Sink(collect_sink_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context(
|
||||
"req-pub-5",
|
||||
Some(identity_with_scopes("user", &["read"])),
|
||||
None,
|
||||
false,
|
||||
ScopedPeerEnv::empty(),
|
||||
);
|
||||
let stream = make_publish_stream(vec![]);
|
||||
let response = registry
|
||||
.invoke_sink("fs/upload", serde_json::json!({}), stream, ctx)
|
||||
.await;
|
||||
match response.result {
|
||||
Err(e) => {
|
||||
assert_eq!(e.code, "FORBIDDEN");
|
||||
assert!(e.message.contains("fs:write"));
|
||||
}
|
||||
other => panic!("expected FORBIDDEN, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_on_pub_op_returns_invalid_operation_type() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
pub_spec("fs/upload", AccessControl::default()),
|
||||
HandlerKind::Sink(collect_sink_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context("req-pub-6", None, None, false, ScopedPeerEnv::empty());
|
||||
let response = registry
|
||||
.invoke("fs/upload", serde_json::json!({}), ctx)
|
||||
.await;
|
||||
match response.result {
|
||||
Err(e) => assert_eq!(e.code, "INVALID_OPERATION_TYPE"),
|
||||
other => panic!("expected INVALID_OPERATION_TYPE, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_streaming_on_pub_op_returns_invalid_operation_type() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
pub_spec("fs/upload", AccessControl::default()),
|
||||
HandlerKind::Sink(collect_sink_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context("req-pub-7", None, None, false, ScopedPeerEnv::empty());
|
||||
let stream = registry.invoke_streaming("fs/upload", serde_json::json!({}), ctx);
|
||||
let items = collect_stream(stream).await;
|
||||
assert_eq!(items.len(), 1);
|
||||
match &items[0].result {
|
||||
Err(e) => assert_eq!(e.code, "INVALID_OPERATION_TYPE"),
|
||||
other => panic!("expected INVALID_OPERATION_TYPE, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_sink_on_query_op_returns_invalid_operation_type() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("echo", AccessControl::default()),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context("req-pub-8", None, None, false, ScopedPeerEnv::empty());
|
||||
let stream = make_publish_stream(vec![]);
|
||||
let response = registry
|
||||
.invoke_sink("echo", serde_json::json!({}), stream, ctx)
|
||||
.await;
|
||||
match response.result {
|
||||
Err(e) => assert_eq!(e.code, "INVALID_OPERATION_TYPE"),
|
||||
other => panic!("expected INVALID_OPERATION_TYPE, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_sink_on_sub_op_returns_invalid_operation_type() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
subscription_spec("events/stream"),
|
||||
HandlerKind::Stream(echo_streaming_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context("req-pub-9", None, None, false, ScopedPeerEnv::empty());
|
||||
let stream = make_publish_stream(vec![]);
|
||||
let response = registry
|
||||
.invoke_sink("events/stream", serde_json::json!({}), stream, ctx)
|
||||
.await;
|
||||
match response.result {
|
||||
Err(e) => assert_eq!(e.code, "INVALID_OPERATION_TYPE"),
|
||||
other => panic!("expected INVALID_OPERATION_TYPE, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_rejects_once_for_pub_spec() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let result = registry.register(HandlerRegistration::new(
|
||||
pub_spec("fs/upload", AccessControl::default()),
|
||||
HandlerKind::Once(echo_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
match result {
|
||||
Err(msg) => assert!(
|
||||
msg.contains("Pub")
|
||||
&& msg.contains("HandlerKind::Sink")
|
||||
&& msg.contains("HandlerKind::Once"),
|
||||
"unexpected message: {msg}"
|
||||
),
|
||||
other => panic!("expected Err, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_rejects_stream_for_pub_spec() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let result = registry.register(HandlerRegistration::new(
|
||||
pub_spec("fs/upload", AccessControl::default()),
|
||||
HandlerKind::Stream(echo_streaming_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
match result {
|
||||
Err(msg) => assert!(
|
||||
msg.contains("Pub")
|
||||
&& msg.contains("HandlerKind::Sink")
|
||||
&& msg.contains("HandlerKind::Stream"),
|
||||
"unexpected message: {msg}"
|
||||
),
|
||||
other => panic!("expected Err, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_rejects_sink_for_query_spec() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let result = registry.register(HandlerRegistration::new(
|
||||
external_spec("echo", AccessControl::default()),
|
||||
HandlerKind::Sink(collect_sink_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
match result {
|
||||
Err(msg) => assert!(
|
||||
msg.contains("Query") || msg.contains("Mutation"),
|
||||
"unexpected message: {msg}"
|
||||
),
|
||||
other => panic!("expected Err, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_rejects_sink_for_sub_spec() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let result = registry.register(HandlerRegistration::new(
|
||||
subscription_spec("events/stream"),
|
||||
HandlerKind::Sink(collect_sink_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
match result {
|
||||
Err(msg) => assert!(
|
||||
msg.contains("Sub")
|
||||
&& msg.contains("HandlerKind::Stream")
|
||||
&& msg.contains("HandlerKind::Sink"),
|
||||
"unexpected message: {msg}"
|
||||
),
|
||||
other => panic!("expected Err, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_sink_internal_call_uses_handler_identity_for_acl() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let composing_authority = CompositionAuthority::new("fs-writer", ["fs:write".to_string()]);
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
internal_pub_spec(AccessControl {
|
||||
required_scopes: vec!["fs:write".to_string()],
|
||||
..Default::default()
|
||||
}),
|
||||
HandlerKind::Sink(collect_sink_handler()),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let ctx = root_context(
|
||||
"req-pub-10",
|
||||
Some(identity_with_scopes("user", &["read"])),
|
||||
Some(composing_authority),
|
||||
true,
|
||||
ScopedPeerEnv::empty(),
|
||||
);
|
||||
let stream = make_publish_stream(vec![serde_json::json!({"chunk": 1})]);
|
||||
let response = registry
|
||||
.invoke_sink("fs/upload", serde_json::json!({}), stream, ctx)
|
||||
.await;
|
||||
let out = response.result.expect("ok — handler identity has fs:write");
|
||||
assert_eq!(out["count"], serde_json::json!(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_with_local_sink_sets_provenance_local() {
|
||||
let registry = OperationRegistryBuilder::new()
|
||||
.with_local_sink(
|
||||
pub_spec("fs/upload", AccessControl::default()),
|
||||
collect_sink_handler(),
|
||||
CompositionAuthority::none(),
|
||||
ScopedPeerEnv::empty().into(),
|
||||
Capabilities::new(),
|
||||
)
|
||||
.unwrap()
|
||||
.build();
|
||||
let reg = registry.registration("fs/upload").expect("registered");
|
||||
assert_eq!(reg.provenance, OperationProvenance::Local);
|
||||
assert!(matches!(reg.handler, HandlerKind::Sink(_)));
|
||||
assert!(reg.composition_authority.is_none());
|
||||
assert!(reg.scoped_env.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_with_leaf_sink_sets_provenance_and_no_authority() {
|
||||
let registry = OperationRegistryBuilder::new()
|
||||
.with_leaf_sink(
|
||||
pub_spec("fs/upload", AccessControl::default()),
|
||||
collect_sink_handler(),
|
||||
Capabilities::new(),
|
||||
)
|
||||
.unwrap()
|
||||
.build();
|
||||
let reg = registry.registration("fs/upload").expect("registered");
|
||||
assert_eq!(reg.provenance, OperationProvenance::FromOpenAPI);
|
||||
assert!(reg.composition_authority.is_none());
|
||||
assert!(reg.scoped_env.is_none());
|
||||
assert!(matches!(reg.handler, HandlerKind::Sink(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_with_leaf_sink_provenance_overrides_provenance() {
|
||||
let registry = OperationRegistryBuilder::new()
|
||||
.with_leaf_sink_provenance(
|
||||
pub_spec("fs/upload", AccessControl::default()),
|
||||
collect_sink_handler(),
|
||||
OperationProvenance::FromCall,
|
||||
Capabilities::new(),
|
||||
)
|
||||
.unwrap()
|
||||
.build();
|
||||
let reg = registry.registration("fs/upload").expect("registered");
|
||||
assert_eq!(reg.provenance, OperationProvenance::FromCall);
|
||||
assert!(matches!(reg.handler, HandlerKind::Sink(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ use serde_json::Value;
|
||||
pub enum OperationType {
|
||||
Query,
|
||||
Mutation,
|
||||
Subscription,
|
||||
Sub,
|
||||
Pub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -148,11 +149,17 @@ pub struct OperationSpec {
|
||||
pub access_control: AccessControl,
|
||||
/// JSON pointer into the input for the resource ID, when
|
||||
/// `access_control.resource_type` is set and the operation targets a
|
||||
/// specific runtime-spawned resource (ADR-050). e.g. `"$.containerId"`
|
||||
/// specific runtime-spawned resource (ADR-011). e.g. `"$.containerId"`
|
||||
/// for `docker/container/exec`. Absent for no-specific-resource
|
||||
/// operations (the `list` case). `None` for operations with no
|
||||
/// `resource_type` or with static resource sets.
|
||||
pub resource_id_path: Option<String>,
|
||||
/// Schema for each published chunk's `input` (Pub ops only, ADR-046).
|
||||
/// `None` for Query/Mutation/Sub ops. When set, the dispatch path
|
||||
/// validates each `call.published` event's `payload.input` against
|
||||
/// this schema before yielding it to the `SinkHandler`. When `None`
|
||||
/// (Pub op with no per-chunk validation), chunks are yielded as-is.
|
||||
pub publish_schema: Option<Value>,
|
||||
}
|
||||
|
||||
impl OperationSpec {
|
||||
@@ -184,9 +191,18 @@ impl OperationSpec {
|
||||
error_schemas,
|
||||
access_control,
|
||||
resource_id_path,
|
||||
publish_schema: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the `publish_schema` (Pub ops only, ADR-046). Validates each
|
||||
/// `call.published` chunk's `input`. Builder-style; returns `self`
|
||||
/// for chaining at registration sites.
|
||||
pub fn with_publish_schema(mut self, schema: Value) -> Self {
|
||||
self.publish_schema = Some(schema);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn path(&self) -> String {
|
||||
format!("/{}", self.name)
|
||||
}
|
||||
@@ -231,7 +247,7 @@ mod tests {
|
||||
fn namespace_derived_from_name() {
|
||||
let spec = OperationSpec::new(
|
||||
"agent/chat",
|
||||
OperationType::Subscription,
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
|
||||
Reference in New Issue
Block a user