Skip to main content

onlyne_client/session/
slice.rs

1//! Role-slice hot reload after `Event::SpecReloaded`.
2//!
3//! `reconfigure` currently only runs on `welcome`. A live connection that
4//! stays up across `onlyne reload` never sees that frame, so this module
5//! compares the `query_roles` row against the dispatcher's current slice
6//! and calls `reconfigure` only when `runtime`, `max_sessions`, or the role's
7//! `owes_targets` changed.
8//!
9//! The drive travels in the slice because it is the runtime's property, read
10//! from the spec's `[client.runtime]`; the placement it pairs with is the
11//! machine's and never appears here (`docs/v2-PLAN.md` §"驱动与放置").
12
13use onlyne_config::Drive;
14use onlyne_proto::{RoleInfo, Welcome};
15
16/// The fields `reconfigure` consumes.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct RoleSlice {
19    /// How the client talks to the runtime (`[client.runtime] drive`).
20    pub drive: Drive,
21    pub command: Vec<String>,
22    pub max_sessions: u32,
23    /// The roles a session of this role owes a delivery to (`owes_targets`).
24    /// Reach and obligation are separate declarations: the server gates the
25    /// ACL on `allowed_targets`, and the completion guard owes this list.
26    pub required_targets: Vec<String>,
27}
28
29impl RoleSlice {
30    pub fn from_welcome(welcome: &Welcome) -> Self {
31        let runtime = welcome.runtime.clone().unwrap_or_default();
32        Self {
33            drive: drive_of(runtime.drive),
34            command: runtime.command,
35            max_sessions: welcome.max_sessions,
36            required_targets: welcome.owes_targets.clone(),
37        }
38    }
39
40    pub fn from_role_info(info: &RoleInfo, _current: &RoleSlice) -> Self {
41        Self {
42            drive: drive_of(info.runtime.drive),
43            command: info.runtime.command.clone(),
44            max_sessions: info.max_sessions,
45            required_targets: info.owes_targets.clone(),
46        }
47    }
48}
49
50/// The wire's drive as the file's drive.
51///
52/// The two vocabularies are separate on purpose — `onlyne-config` owns the
53/// spelling a file uses and `onlyne-proto` the one a frame uses — so this is
54/// the one place the client crosses between them, and the pair rule
55/// (`onlyne_config::validate_drive_placement`) is written in the file's terms.
56pub fn drive_of(wire: onlyne_proto::Drive) -> Drive {
57    match wire {
58        onlyne_proto::Drive::Plugin => Drive::Plugin,
59        onlyne_proto::Drive::Acp => Drive::Acp,
60        onlyne_proto::Drive::Exec => Drive::Exec,
61    }
62}
63
64/// Fields that would change if `next` were applied.
65pub fn slice_diff(current: &RoleSlice, next: &RoleSlice) -> Vec<&'static str> {
66    let mut fields = Vec::new();
67    if current.drive != next.drive || current.command != next.command {
68        fields.push("runtime");
69    }
70    if current.max_sessions != next.max_sessions {
71        fields.push("max_sessions");
72    }
73    if current.required_targets != next.required_targets {
74        fields.push("owes_targets");
75    }
76    fields
77}
78
79/// Apply `next` when it differs; return the fields that changed.
80pub fn apply_if_changed(
81    current: &RoleSlice,
82    next: RoleSlice,
83) -> Option<(RoleSlice, Vec<&'static str>)> {
84    let fields = slice_diff(current, &next);
85    if fields.is_empty() {
86        None
87    } else {
88        Some((next, fields))
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    fn welcome(edges: &[&str], owes: &[&str]) -> Welcome {
97        Welcome {
98            cluster: "cluster-a".to_string(),
99            server: "srv".to_string(),
100            role: "planner".to_string(),
101            admin: false,
102            max_sessions: 3,
103            prose: String::new(),
104            spec_hash: "hash".to_string(),
105            aggregate: None,
106            allowed_targets: edges.iter().map(|name| name.to_string()).collect(),
107            owes_targets: owes.iter().map(|name| name.to_string()).collect(),
108            allowed_senders: Vec::new(),
109            runtime: None,
110            timeout_ready_ms: None,
111            timeout_idle_ms: None,
112            intent_attempts: None,
113            intent_backoff_ms: None,
114            seq: 1,
115        }
116    }
117
118    /// Reach is permission and never compels a delivery: the slice's obligation
119    /// is `owes_targets`, and `allowed_targets` — which may name ten roles the
120    /// session never owes — is not read here at all.
121    #[test]
122    fn the_slice_owes_owes_targets_not_the_reach() {
123        let slice = RoleSlice::from_welcome(&welcome(&["writer", "auditor"], &["writer"]));
124        assert_eq!(slice.required_targets, ["writer"], "{slice:?}");
125
126        let reach_only = RoleSlice::from_welcome(&welcome(&["writer", "auditor"], &[]));
127        assert!(
128            reach_only.required_targets.is_empty(),
129            "a role that declares no obligation owes nothing: {reach_only:?}"
130        );
131    }
132
133    /// A reload that moves only the obligation is worth a `reconfigure`, and
134    /// one that moves only the reach is not — the client holds no ACL.
135    #[test]
136    fn the_slice_diff_reports_the_obligation_alone() {
137        let base = RoleSlice::from_welcome(&welcome(&["writer"], &["writer"]));
138        let moved_obligation =
139            RoleSlice::from_welcome(&welcome(&["writer", "auditor"], &["auditor"]));
140        assert_eq!(
141            slice_diff(&base, &moved_obligation),
142            vec!["owes_targets"],
143            "the reach moved too, and it is the server's to read"
144        );
145        let moved_reach = RoleSlice::from_welcome(&welcome(&["writer", "auditor"], &["writer"]));
146        assert!(
147            slice_diff(&base, &moved_reach).is_empty(),
148            "a reach-only change reconfigures nothing on the client"
149        );
150    }
151}