flybrain/services/flysim/crates/fly-session/src/rpc.rs
acamilo 52ad46ecb0 refactor(session): build the session slice on the shared fly-session-types crate
CONTRACT-01 landed its crate, so the local stand-in module goes away and the domain
scalars, method payloads, their validation, the canonical digests and the trace format
all come from the contract. `src/types.rs` is a facade over that crate plus the few
things a coordinator needs that are not part of the type contract: a session-side
DomainError, the synthetic composition's schema and event-id derivations, and the
coordinator-local ControllerIntent, PortBinding and AgentOutcome that never cross the
bus.

Consequences worth naming:

- Payloads are read and written through DomainType::from_json / to_json instead of
  serde derives, so a misspelled required field fails where the contract says it
  should. serde, sha2 and ryu-js leave this crate's dependencies with them.
- The step-v1 section 8 trace is the contract's TransitionTrace, with behaviour and
  operational metadata already separated; the dispatch-order comparison now runs over
  the contract's own behaviour encoding.
- Two things the migration found. IN_PROGRESS is raised strictly before any mutation,
  so its certainty is "none", not "unknown"; the local module had it wrong.
  WorldObservation::validate_against checks the views a result carries but does not
  require every declared view to be present, so requiring them is made explicit in the
  coordinator's phase C check, where step-v1 puts it.
- The lost-Advance-result injection now waits for the worker to report the operation
  before abandoning the call, so the case it injects really is a loss after dispatch
  rather than a cancellation before it.

Gates: cargo test -p fly-session (61 tests, both transports), cargo clippy
--all-targets clean, and the runnable example produces the same behaviour trace over
both transports.
2026-09-22 12:30:41 +00:00

139 lines
5.2 KiB
Rust

//! Domain RPC over Flybus: `req-<u64>` serials, incarnation pinning and the retry rule.
//!
//! A domain retry keeps its `requestId` and body and takes a fresh bus `callId`. Nothing here
//! retries on its own: an uncertain call is resolved by the caller, which is the coordinator.
use std::collections::BTreeMap;
use serde_json::{Map, Value};
// `crate::types` is this crate's facade over the shared `fly-session-types` crate; the
// glob keeps the contract's own names in sight instead of restating them.
use crate::types::*;
/// A named worker endpoint, pinned to one bus registration and one domain incarnation.
#[derive(Clone, Debug)]
pub struct WorkerRef {
pub service: String,
/// The bus `serviceIncarnation` every call pins. Not a worker process id.
pub bus_incarnation: String,
pub worker_id: Id,
/// The domain `incarnationId` Hello negotiated, once it has.
pub domain_incarnation: Option<Id>,
}
impl WorkerRef {
pub fn new(service: &str, bus_incarnation: &str, worker_id: &Id) -> WorkerRef {
WorkerRef {
service: service.to_owned(),
bus_incarnation: bus_incarnation.to_owned(),
worker_id: worker_id.clone(),
domain_incarnation: None,
}
}
}
/// One terminal domain reply and the artifacts it brought.
pub struct DomainReply {
pub outcome: SessionRpcOutcome,
pub request_id: DomainRequestId,
pub artifacts: BTreeMap<String, flybus::Artifact>,
}
impl DomainReply {
/// The success `result`, or the domain error.
pub fn result(&self) -> Result<&Value, DomainError> {
outcome_result(&self.outcome)
}
/// Reads and validates the success `result` as a method payload.
pub fn parse<T: DomainType>(&self) -> Result<T, DomainError> {
let result = outcome_result(&self.outcome)?;
T::from_json(result).map_err(|e| DomainError::invalid(format!("unreadable result: {e}")))
}
}
/// Issues one domain call and waits for its terminal reply.
///
/// `want_artifacts` names the attachments to extract before the result delivery is dropped.
/// A bus-level failure is not a domain failure: it is reported with the dispatch certainty the
/// bus gave, because a caller-side timeout must not imply that nothing was mutated.
#[allow(clippy::too_many_arguments)]
pub async fn call(
bus: &flybus::Client,
target: &WorkerRef,
method: &str,
scope: Option<Scope>,
params: Map<String, Value>,
attachments: &[(&str, &flybus::Artifact)],
request_id: DomainRequestId,
want_artifacts: &[String],
) -> Result<DomainReply, DomainError> {
let request = SessionRpcRequest { request_id: request_id.clone(), scope, params: Value::Object(params) };
let mut pending = bus
.call(
&target.service,
Some(&target.bus_incarnation),
method,
object(request.to_json()),
attachments,
)
.await
.map_err(|e| bus_error(method, &e))?;
let result = pending.result().await.map_err(|e| bus_error(method, &e))?;
let outcome = SessionRpcOutcome::from_json(&Value::Object(result.outcome().clone()))
.map_err(|e| DomainError::invalid(format!("{method}: {e}")))?;
let mut artifacts = BTreeMap::new();
for name in want_artifacts {
if let Ok(artifact) = result.artifact(name) {
// An independent explicit hold, so the handle outlives this delivery and can be
// forwarded to several Commit calls and to publication.
match artifact.retain().await {
Ok(hold) => {
artifacts.insert(name.clone(), hold);
}
Err(_) => {
artifacts.insert(name.clone(), artifact);
}
}
}
}
drop(result);
Ok(DomainReply { outcome, request_id, artifacts })
}
/// Maps a bus failure onto a domain error, preserving how certain the mutation is.
fn bus_error(method: &str, e: &flybus::BusError) -> DomainError {
let mutation = match e.dispatch {
flybus::Dispatch::NotDispatched => MutationCertainty::None,
flybus::Dispatch::Dispatched | flybus::Dispatch::Unknown => MutationCertainty::Unknown,
};
let code = match e.code {
flybus::ErrorCode::TargetChanged | flybus::ErrorCode::NoService => {
ErrorCode::IdentityMismatch
}
flybus::ErrorCode::Backpressure | flybus::ErrorCode::QuotaExceeded => ErrorCode::Busy,
flybus::ErrorCode::ArtifactGone
| flybus::ErrorCode::ArtifactUnsealed
| flybus::ErrorCode::OwnerInvalid
| flybus::ErrorCode::ArtifactMismatch => ErrorCode::BufferInvalid,
_ => ErrorCode::BackendFailure,
};
DomainError::new(code, format!("{method}: bus {:?}: {}", e.code, e.message), mutation)
}
/// Per-worker request serials. A newly issued operation takes the next one; a retry does not.
#[derive(Clone, Debug, Default)]
pub struct Serials(BTreeMap<String, u64>);
impl Serials {
pub fn next(&mut self, service: &str) -> DomainRequestId {
let slot = self.0.entry(service.to_owned()).or_insert(0);
*slot += 1;
DomainRequestId::from_serial(*slot)
}
pub fn highest(&self, service: &str) -> u64 {
self.0.get(service).copied().unwrap_or_default()
}
}