Skip to main content

a3s_box_runtime/scale/
authority.rs

1//! Durable desired-state authority for the standalone Gateway scale contract.
2
3use 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}