a3s_box_runtime/scale/
authority.rs1use std::path::{Path, PathBuf};
4
5use a3s_box_core::scale::{
6 ScaleObservation, ScaleOperationConflict, ScaleOperationRequest, ScaleOperationResponse,
7};
8use thiserror::Error;
9
10use super::manager::ScaleAuthorityState;
11use super::ScaleManager;
12
13#[derive(Debug, Error)]
14pub enum ScaleAuthorityError {
15 #[error("scale operation conflict: {0}")]
16 Conflict(String, ScaleOperationConflict),
17 #[error("scale authority state error: {0}")]
18 State(String),
19}
20
21impl ScaleAuthorityError {
22 pub fn conflict(&self) -> Option<&ScaleOperationConflict> {
23 match self {
24 Self::Conflict(_, conflict) => Some(conflict),
25 Self::State(_) => None,
26 }
27 }
28}
29
30pub struct DurableScaleAuthority {
31 path: PathBuf,
32 manager: ScaleManager,
33}
34
35impl DurableScaleAuthority {
36 pub fn open(path: impl Into<PathBuf>, max_instances: u32) -> Result<Self, ScaleAuthorityError> {
37 let path = path.into();
38 let mut manager = ScaleManager::new(max_instances);
39 if path.exists() {
40 let bytes = std::fs::read(&path).map_err(|error| {
41 ScaleAuthorityError::State(format!("failed to read {}: {error}", path.display()))
42 })?;
43 let state: ScaleAuthorityState = serde_json::from_slice(&bytes).map_err(|error| {
44 ScaleAuthorityError::State(format!("failed to parse {}: {error}", path.display()))
45 })?;
46 manager
47 .restore_authority_state(state)
48 .map_err(ScaleAuthorityError::State)?;
49 }
50 Ok(Self { path, manager })
51 }
52
53 pub fn observation(&self, service: &str) -> ScaleObservation {
54 self.manager.scale_observation(service)
55 }
56
57 pub fn apply(
58 &mut self,
59 request: &ScaleOperationRequest,
60 ) -> Result<ScaleOperationResponse, ScaleAuthorityError> {
61 let previous = self.manager.authority_state();
62 let response = self.manager.apply_operation(request).map_err(|conflict| {
63 ScaleAuthorityError::Conflict(conflict.message.clone(), conflict)
64 })?;
65 if let Err(error) = persist(&self.path, &self.manager.authority_state()) {
66 if let Err(rollback) = self.manager.restore_authority_state(previous) {
67 return Err(ScaleAuthorityError::State(format!(
68 "{error}; failed to restore in-memory state: {rollback}"
69 )));
70 }
71 return Err(error);
72 }
73 Ok(response)
74 }
75
76 pub fn finalize(
77 &mut self,
78 request: &ScaleOperationRequest,
79 response: ScaleOperationResponse,
80 ) -> Result<ScaleOperationResponse, ScaleAuthorityError> {
81 let previous = self.manager.authority_state();
82 self.manager
83 .finalize_operation_response(request, response.clone())
84 .map_err(ScaleAuthorityError::State)?;
85 if let Err(error) = persist(&self.path, &self.manager.authority_state()) {
86 if let Err(rollback) = self.manager.restore_authority_state(previous) {
87 return Err(ScaleAuthorityError::State(format!(
88 "{error}; failed to restore in-memory state: {rollback}"
89 )));
90 }
91 return Err(error);
92 }
93 Ok(response)
94 }
95}
96
97fn persist(path: &Path, state: &ScaleAuthorityState) -> Result<(), ScaleAuthorityError> {
98 let parent = path.parent().ok_or_else(|| {
99 ScaleAuthorityError::State(format!("{} has no parent directory", path.display()))
100 })?;
101 std::fs::create_dir_all(parent).map_err(|error| {
102 ScaleAuthorityError::State(format!("failed to create {}: {error}", parent.display()))
103 })?;
104 let bytes = serde_json::to_vec(state).map_err(|error| {
105 ScaleAuthorityError::State(format!("failed to encode scale authority: {error}"))
106 })?;
107 let temporary = path.with_extension("tmp");
108 a3s_box_core::fs_atomic::write_durable(&temporary, path, &bytes).map_err(|error| {
109 ScaleAuthorityError::State(format!("failed to persist {}: {error}", path.display()))
110 })
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use a3s_box_core::scale::{ScaleDirection, SCALE_OPERATION_SCHEMA_VERSION};
117
118 fn request(id: &str, revision: &str, current: u32, desired: u32) -> ScaleOperationRequest {
119 ScaleOperationRequest {
120 schema_version: SCALE_OPERATION_SCHEMA_VERSION,
121 operation_id: id.to_string(),
122 service: "api".to_string(),
123 expected_revision: Some(revision.to_string()),
124 direction: if desired > current {
125 ScaleDirection::Up
126 } else {
127 ScaleDirection::Down
128 },
129 current_replicas: current,
130 desired_replicas: desired,
131 reason: "fixture load".to_string(),
132 }
133 }
134
135 #[test]
136 fn restart_retains_revision_and_exact_operation_replay() {
137 let directory = tempfile::tempdir().unwrap();
138 let path = directory.path().join("scale-authority.json");
139 let operation = request("scale-v1-restart", "0", 0, 2);
140 let accepted = {
141 let mut authority = DurableScaleAuthority::open(&path, 10).unwrap();
142 authority.apply(&operation).unwrap()
143 };
144
145 let mut reopened = DurableScaleAuthority::open(&path, 10).unwrap();
146 assert_eq!(reopened.observation("api").replicas, 2);
147 assert_eq!(reopened.observation("api").revision.as_deref(), Some("1"));
148 assert_eq!(reopened.apply(&operation).unwrap(), accepted);
149
150 let conflict = reopened
151 .apply(&request("scale-v1-stale", "0", 0, 3))
152 .unwrap_err();
153 assert_eq!(conflict.conflict().unwrap().code, "stale_revision");
154 }
155
156 #[test]
157 fn restart_replays_the_durable_reconciled_response() {
158 let directory = tempfile::tempdir().unwrap();
159 let path = directory.path().join("scale-authority.json");
160 let operation = request("scale-v1-finalized", "0", 0, 2);
161 let finalized = {
162 let mut authority = DurableScaleAuthority::open(&path, 10).unwrap();
163 let accepted = authority.apply(&operation).unwrap();
164 authority
165 .finalize(
166 &operation,
167 ScaleOperationResponse {
168 actual_replicas: 1,
169 message: "Box reconciled one ready replica".to_string(),
170 ..accepted
171 },
172 )
173 .unwrap()
174 };
175
176 let mut reopened = DurableScaleAuthority::open(&path, 10).unwrap();
177 assert_eq!(reopened.apply(&operation).unwrap(), finalized);
178 assert_eq!(finalized.actual_replicas, 1);
179 assert_eq!(finalized.revision.as_deref(), Some("1"));
180 }
181
182 #[test]
183 fn corrupt_journal_fails_closed() {
184 let directory = tempfile::tempdir().unwrap();
185 let path = directory.path().join("scale-authority.json");
186 std::fs::write(&path, b"not-json").unwrap();
187
188 let error = match DurableScaleAuthority::open(path, 10) {
189 Ok(_) => panic!("corrupt journal must fail closed"),
190 Err(error) => error,
191 };
192
193 assert!(error.to_string().contains("failed to parse"));
194 }
195
196 #[test]
197 fn persistence_failure_rolls_back_in_memory_transition() {
198 let directory = tempfile::tempdir().unwrap();
199 let blocked_parent = directory.path().join("not-a-directory");
200 std::fs::write(&blocked_parent, b"file").unwrap();
201 let mut authority =
202 DurableScaleAuthority::open(blocked_parent.join("state.json"), 10).unwrap();
203
204 let error = authority
205 .apply(&request("scale-v1-fail", "0", 0, 2))
206 .unwrap_err();
207
208 assert!(error.conflict().is_none());
209 assert_eq!(authority.observation("api").replicas, 0);
210 assert_eq!(authority.observation("api").revision.as_deref(), Some("0"));
211 }
212}