flybrain/services/flysim/crates/fly-session/src/dedup.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

446 lines
17 KiB
Rust

//! Domain deduplication and the result cache of `ipc-v1` section 5.
//!
//! The operation key for a step mutation is `(sessionId, epoch, step, method, workerId)`, and
//! there is at most one Prepare, Commit or Advance for it. The cache decides, *before* any
//! phase check or artifact dereference, whether an arriving request is the original, a safe
//! replay, a conflicting change, a duplicate of something still running, or a retry whose
//! result is gone.
//!
//! A cached reply owns its artifacts through explicit holds, so a replay is still valid after
//! the first caller consumed its delivery. Dropping the record drops those holds.
use std::collections::{BTreeMap, VecDeque};
// `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::*;
/// `ipc-v1` section 5: unacknowledged lifecycle replies are bounded at 16, then BUSY.
pub const MAX_UNACKNOWLEDGED: usize = 16;
/// `ipc-v1` section 5: Status and Acknowledge keep a cache of their last 16 replies.
pub const MAX_READONLY_REPLIES: usize = 16;
/// `ipc-v1` section 5: keep the current and the immediately previous step's records.
pub const RETAINED_STEPS: u64 = 2;
/// Which retention rule an operation follows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OpClass {
/// Prepare, Commit, Advance: keyed by scope and retained for two steps.
StepMutation,
/// Initialize and capture: retained until `Worker.Acknowledge`.
Lifecycle,
/// Status and Acknowledge: a small last-16 reply cache, no mutation key.
ReadOnly,
}
/// A step mutation's identity. Not a bus call id and not a batch id.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct OperationKey {
pub session_id: Id,
pub epoch: Id,
pub step: u64,
pub method: String,
pub worker_id: Id,
}
/// A terminal domain reply plus the artifact holds that keep its attachments readable.
#[derive(Clone)]
pub struct CachedReply {
pub outcome: SessionRpcOutcome,
pub artifacts: Vec<(String, flybus::Artifact)>,
}
impl CachedReply {
pub fn new(outcome: SessionRpcOutcome) -> CachedReply {
CachedReply { outcome, artifacts: Vec::new() }
}
pub fn with_artifacts(
outcome: SessionRpcOutcome,
artifacts: Vec<(String, flybus::Artifact)>,
) -> CachedReply {
CachedReply { outcome, artifacts }
}
/// The attachment list for a fresh `rpc.reply`, over the same immutable bytes.
pub fn attachments(&self) -> Vec<(&str, &flybus::Artifact)> {
self.artifacts.iter().map(|(n, a)| (n.as_str(), a)).collect()
}
}
impl std::fmt::Debug for CachedReply {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CachedReply")
.field("outcome", &self.outcome)
.field("artifacts", &self.artifacts.len())
.finish()
}
}
/// What the cache decided about an arriving request.
#[derive(Debug)]
pub enum Admission {
/// The original: run it, then `record` its reply.
Execute,
/// A safe replay: return this reply again, with fresh delivery ownership.
Replay(CachedReply),
/// A terminal domain error for this bus call, with no second mutation started.
Refuse(DomainError),
}
#[derive(Clone, Debug)]
struct Record {
request_id: DomainRequestId,
body: Digest,
reply: CachedReply,
}
/// One worker's domain request cache.
pub struct ResultCache {
steps: BTreeMap<OperationKey, Record>,
active: BTreeMap<OperationKey, (DomainRequestId, Digest)>,
lifecycle: BTreeMap<String, Record>,
lifecycle_order: VecDeque<String>,
readonly: VecDeque<(String, CachedReply)>,
highest_serial: Option<u64>,
step_watermark: Option<u64>,
/// Serials retired by `Worker.Acknowledge`; reuse below this is refused without keeping a
/// tombstone per request.
acknowledged_watermark: Option<u64>,
}
impl Default for ResultCache {
fn default() -> ResultCache {
ResultCache::new()
}
}
impl ResultCache {
pub fn new() -> ResultCache {
ResultCache {
steps: BTreeMap::new(),
active: BTreeMap::new(),
lifecycle: BTreeMap::new(),
lifecycle_order: VecDeque::new(),
readonly: VecDeque::new(),
highest_serial: None,
step_watermark: None,
acknowledged_watermark: None,
}
}
/// Decides what to do with an arriving request. Identity is checked before phase.
pub fn admit(
&mut self,
class: OpClass,
key: &OperationKey,
request: DomainRequestId,
body: &Digest,
) -> Admission {
match class {
OpClass::StepMutation => self.admit_step(key, request, body),
OpClass::Lifecycle => self.admit_lifecycle(request, body),
OpClass::ReadOnly => Admission::Execute,
}
}
fn admit_step(&mut self, key: &OperationKey, request: DomainRequestId, body: &Digest) -> Admission {
if let Some(record) = self.steps.get(key) {
if record.request_id == request && record.body == *body {
return Admission::Replay(record.reply.clone());
}
return Admission::Refuse(DomainError::new(
ErrorCode::Conflict,
"this operation key already holds a different request or body",
// The earlier result stands; refusing the duplicate undoes nothing.
MutationCertainty::None,
));
}
if !self.active.is_empty() && !self.active.contains_key(key) {
// One mutation executes at a time. A second, different one is refused before
// admission rather than queued behind the first.
return Admission::Refuse(DomainError::before(
ErrorCode::Busy,
"another step mutation is already executing on this worker",
));
}
if let Some((active_request, active_body)) = self.active.get(key) {
if *active_request == request && *active_body == *body {
// The duplicate bus call started no work at all, so its certainty is none;
// it must not be mistaken for the original operation's failure.
return Admission::Refuse(DomainError::before(
ErrorCode::InProgress,
"the original operation is still executing; this duplicate started no work",
));
}
return Admission::Refuse(DomainError::new(
ErrorCode::Conflict,
"an operation with a different body is active for this key",
MutationCertainty::None,
));
}
// No record and nothing active. Eviction must never re-enable execution, so a step
// below the retention window is refused on the serial and step watermarks.
if let Some(watermark) = self.step_watermark
&& key.step + RETAINED_STEPS <= watermark
{
let issued = self.highest_serial.is_some_and(|h| request.serial() <= h);
return Admission::Refuse(if issued {
DomainError::new(
ErrorCode::ResultExpired,
"the retained result for this request is gone; it is never recomputed",
MutationCertainty::Unknown,
)
} else {
DomainError::before(
ErrorCode::StaleStep,
"a newly issued request cannot name a step the worker has left behind",
)
});
}
if let Some(watermark) = self.acknowledged_watermark
&& request.serial() <= watermark
{
return Admission::Refuse(DomainError::new(
ErrorCode::ResultExpired,
"this request serial was acknowledged and cannot be reused",
MutationCertainty::Unknown,
));
}
self.begin(key.clone(), request, body.clone());
Admission::Execute
}
fn admit_lifecycle(&mut self, request: DomainRequestId, body: &Digest) -> Admission {
let id = request.as_str().to_owned();
if let Some(record) = self.lifecycle.get(&id) {
if record.body == *body {
return Admission::Replay(record.reply.clone());
}
return Admission::Refuse(DomainError::before(
ErrorCode::Conflict,
"this lifecycle request id already holds a different body",
));
}
if let Some(watermark) = self.acknowledged_watermark
&& request.serial() <= watermark
{
return Admission::Refuse(DomainError::new(
ErrorCode::ResultExpired,
"this request serial was acknowledged and cannot be reused",
MutationCertainty::Unknown,
));
}
if self.lifecycle.len() >= MAX_UNACKNOWLEDGED {
return Admission::Refuse(DomainError::before(
ErrorCode::Busy,
"16 lifecycle replies are unacknowledged; acknowledge some before sending more",
));
}
Admission::Execute
}
fn begin(&mut self, key: OperationKey, request: DomainRequestId, body: Digest) {
self.highest_serial = Some(self.highest_serial.map_or(request.serial(), |h| h.max(request.serial())));
self.step_watermark = Some(self.step_watermark.map_or(key.step, |w| w.max(key.step)));
self.active.insert(key, (request, body));
}
/// Stores a terminal reply for a step mutation and prunes what has aged out.
pub fn record(
&mut self,
key: OperationKey,
request: DomainRequestId,
body: Digest,
reply: CachedReply,
) {
self.active.remove(&key);
let step = key.step;
self.steps.insert(key, Record { request_id: request, body, reply });
self.step_watermark = Some(self.step_watermark.map_or(step, |w| w.max(step)));
self.prune();
}
/// Stores a lifecycle reply, retained until `Worker.Acknowledge`.
pub fn record_lifecycle(&mut self, request: DomainRequestId, body: Digest, reply: CachedReply) {
let id = request.as_str().to_owned();
self.highest_serial = Some(self.highest_serial.map_or(request.serial(), |h| h.max(request.serial())));
if self.lifecycle.insert(id.clone(), Record { request_id: request, body, reply }).is_none()
{
self.lifecycle_order.push_back(id);
}
}
/// Stores a read-only reply in the last-16 cache.
pub fn record_readonly(&mut self, request: DomainRequestId, reply: CachedReply) {
let id = request.as_str().to_owned();
self.readonly.retain(|(existing, _)| *existing != id);
self.readonly.push_back((id, reply));
while self.readonly.len() > MAX_READONLY_REPLIES {
self.readonly.pop_front();
}
}
/// Releases an operation that did not mutate anything, so the key stays free.
pub fn abandon(&mut self, key: &OperationKey) {
self.active.remove(key);
}
/// `Worker.Acknowledge`: drops those lifecycle records, ignoring unknown ids, and raises
/// the serial watermark so an acknowledged id cannot be reused.
pub fn acknowledge(&mut self, ids: &[DomainRequestId]) -> Vec<DomainRequestId> {
let mut out = Vec::new();
for id in ids {
if let Some(record) = self.lifecycle.remove(id.as_str()) {
self.lifecycle_order.retain(|existing| existing != id.as_str());
self.acknowledged_watermark = Some(
self.acknowledged_watermark
.map_or(record.request_id.serial(), |w| w.max(record.request_id.serial())),
);
out.push(id.clone());
}
}
out
}
pub fn unacknowledged(&self) -> usize {
self.lifecycle.len()
}
pub fn retained_steps(&self) -> usize {
self.steps.len()
}
pub fn has_active(&self) -> bool {
!self.active.is_empty()
}
/// Deliberately drops a step record, so a retry meets RESULT_EXPIRED instead of a replay.
pub fn expire_step(&mut self, key: &OperationKey) -> bool {
self.steps.remove(key).is_some()
}
fn prune(&mut self) {
let Some(watermark) = self.step_watermark else { return };
let floor = watermark.saturating_sub(RETAINED_STEPS - 1);
self.steps.retain(|key, _| key.step >= floor);
}
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use super::*;
fn key(step: u64, method: &str) -> OperationKey {
OperationKey {
session_id: id("demo"),
epoch: id("e1"),
step,
method: method.to_owned(),
worker_id: id("fly-a"),
}
}
fn reply(tag: &str) -> CachedReply {
let mut result = serde_json::Map::new();
result.insert("tag".into(), tag.into());
CachedReply::new(SessionRpcOutcome::Success(SessionRpcSuccess {
request_id: DomainRequestId::from_serial(1),
worker_id: id("fly-a"),
incarnation_id: id("inc-1"),
scope: Some(scope_at("demo", "e1", 0)),
result: Value::Object(result),
}))
}
fn body(s: &str) -> Digest {
digest_of_bytes(s.as_bytes())
}
#[test]
fn the_same_key_request_and_body_replays() {
let mut c = ResultCache::new();
let k = key(0, "Agent.Prepare");
assert!(matches!(c.admit(OpClass::StepMutation, &k, DomainRequestId::from_serial(1), &body("a")), Admission::Execute));
c.record(k.clone(), DomainRequestId::from_serial(1), body("a"), reply("first"));
match c.admit(OpClass::StepMutation, &k, DomainRequestId::from_serial(1), &body("a")) {
Admission::Replay(r) => {
assert_eq!(r.outcome.result().unwrap()["tag"], "first");
}
other => panic!("wanted a replay, got {other:?}"),
}
}
#[test]
fn a_changed_body_for_a_recorded_key_is_a_conflict() {
let mut c = ResultCache::new();
let k = key(0, "Environment.Advance");
c.admit(OpClass::StepMutation, &k, DomainRequestId::from_serial(1), &body("a"));
c.record(k.clone(), DomainRequestId::from_serial(1), body("a"), reply("first"));
match c.admit(OpClass::StepMutation, &k, DomainRequestId::from_serial(1), &body("b")) {
Admission::Refuse(e) => assert_eq!(e.code, ErrorCode::Conflict),
other => panic!("wanted CONFLICT, got {other:?}"),
}
}
#[test]
fn a_duplicate_while_the_original_runs_is_in_progress() {
let mut c = ResultCache::new();
let k = key(0, "Agent.Prepare");
c.admit(OpClass::StepMutation, &k, DomainRequestId::from_serial(1), &body("a"));
match c.admit(OpClass::StepMutation, &k, DomainRequestId::from_serial(1), &body("a")) {
Admission::Refuse(e) => assert_eq!(e.code, ErrorCode::InProgress),
other => panic!("wanted IN_PROGRESS, got {other:?}"),
}
}
#[test]
fn an_evicted_step_gives_result_expired_and_a_fresh_serial_gives_stale_step() {
let mut c = ResultCache::new();
for step in 0..4u64 {
let k = key(step, "Agent.Prepare");
c.admit(OpClass::StepMutation, &k, DomainRequestId::from_serial(step + 1), &body("a"));
c.record(k, DomainRequestId::from_serial(step + 1), body("a"), reply("x"));
}
assert_eq!(c.retained_steps(), 2);
// req-1 named step 0, whose record is long gone.
match c.admit(OpClass::StepMutation, &key(0, "Agent.Prepare"), DomainRequestId::from_serial(1), &body("a")) {
Admission::Refuse(e) => assert_eq!(e.code, ErrorCode::ResultExpired),
other => panic!("wanted RESULT_EXPIRED, got {other:?}"),
}
// A serial above the highest issued is a new operation naming an old step.
match c.admit(OpClass::StepMutation, &key(0, "Agent.Prepare"), DomainRequestId::from_serial(99), &body("a")) {
Admission::Refuse(e) => assert_eq!(e.code, ErrorCode::StaleStep),
other => panic!("wanted STALE_STEP, got {other:?}"),
}
}
#[test]
fn lifecycle_replies_are_bounded_and_released_by_acknowledge() {
let mut c = ResultCache::new();
for serial in 1..=MAX_UNACKNOWLEDGED as u64 {
assert!(matches!(
c.admit(OpClass::Lifecycle, &key(0, "Agent.Initialize"), DomainRequestId::from_serial(serial), &body("a")),
Admission::Execute
));
c.record_lifecycle(DomainRequestId::from_serial(serial), body("a"), reply("init"));
}
match c.admit(OpClass::Lifecycle, &key(0, "Agent.Initialize"), DomainRequestId::from_serial(99), &body("a")) {
Admission::Refuse(e) => assert_eq!(e.code, ErrorCode::Busy),
other => panic!("wanted BUSY, got {other:?}"),
}
let dropped = c.acknowledge(&[
DomainRequestId::from_serial(1),
DomainRequestId::from_serial(404),
]);
assert_eq!(dropped, vec![DomainRequestId::from_serial(1)]);
assert_eq!(c.unacknowledged(), MAX_UNACKNOWLEDGED - 1);
// The acknowledged serial cannot come back.
match c.admit(OpClass::Lifecycle, &key(0, "Agent.Initialize"), DomainRequestId::from_serial(1), &body("a")) {
Admission::Refuse(e) => assert_eq!(e.code, ErrorCode::ResultExpired),
other => panic!("wanted RESULT_EXPIRED, got {other:?}"),
}
}
}