feat(#3124): publish the agent set the swarm declares for each hive
The hive-side loop landed without anything to converge to: nothing wrote `$KV.hive-wanted.<hive>`, so in production only the "no key" branch ran. This is the writer. `WantedWriter` mirrors `StatusReader` — that module reads what hives report, this one writes what they are told, so it holds a client rather than a bucket handle and resolves the store on first use. It shares the status reader's connection: the controller has exactly one by design, and a second connect would double the auth-callout traffic and give the two paths independent reconnect state. The value under a hive's key is the map of every agent on that hive, so a plain `put` of a single-agent change would drop a concurrent change to a different agent, with only one revision of history to not recover from. Writes are read-modify-write against the entry revision, and only `WrongLastRevision` / `AlreadyExists` count as a lost race — every other error returns immediately rather than spinning the retry loop and then blaming a concurrent writer that never existed. `apply` is split out and tested because it holds the invariant: declaring one agent preserves the rest, and a current value that will not decode is an error rather than a fresh start. Overwriting a document nobody can read discards every other agent's declaration. Two routes, no swarmctl verb and no jobq node: `create_agent` needs a graph because it is multi-step, and one CAS'd write is not. `build_app` is extracted from `main` in the same change because `main` sat at exactly the `too_many_lines` limit, so adding an endpoint tripped a lint about the startup sequence. The route list is the part that grows.
This commit is contained in:
parent
066f31b58b
commit
76d5871d20
3 changed files with 424 additions and 9 deletions
|
|
@ -63,7 +63,7 @@ pub struct AgentWanted {
|
|||
/// than part of a declaration it only half understands. Adding a state means
|
||||
/// adding a variant here and shipping it to both ends — which is the intended
|
||||
/// workflow, not an obstacle to route around with a catch-all variant.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentState {
|
||||
/// Exists on the hive and is running.
|
||||
|
|
@ -72,12 +72,26 @@ pub enum AgentState {
|
|||
Offline,
|
||||
}
|
||||
|
||||
impl AgentState {
|
||||
/// The wire spelling, for a reader that renders rather than decodes.
|
||||
///
|
||||
/// Kept beside the enum so it cannot drift from the `rename_all` above;
|
||||
/// a test pins the two together.
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
AgentState::Up => "up",
|
||||
AgentState::Offline => "offline",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the wanted-state bucket for writing, creating it if nothing has yet.
|
||||
///
|
||||
/// **Controller-side only.** `history: 1` because a hive converges to the
|
||||
/// current declaration and never asks what the previous one was — that
|
||||
/// question is answered by the controller's own records, not by replaying a
|
||||
/// bucket.
|
||||
/// current declaration and never asks what the previous one was. The
|
||||
/// controller keeps no second copy either: the value published here is the
|
||||
/// record, and it reads it back from this bucket when it needs it.
|
||||
#[cfg(feature = "kv")]
|
||||
pub async fn open_or_create(
|
||||
client: &async_nats::Client,
|
||||
|
|
@ -160,4 +174,22 @@ mod tests {
|
|||
fn a_missing_state_is_an_error() {
|
||||
assert!(serde_json::from_str::<HiveWanted>(r#"{"agents":{"a":{}}}"#).is_err());
|
||||
}
|
||||
|
||||
/// `as_str` and the `rename_all` are two spellings of one fact, and a
|
||||
/// renderer that disagrees with the wire is worse than one that does not
|
||||
/// exist. The `match` is what makes this exhaustive: a new variant fails
|
||||
/// to compile here rather than quietly going untested.
|
||||
#[test]
|
||||
fn as_str_matches_the_serde_spelling() {
|
||||
let every = [AgentState::Up, AgentState::Offline];
|
||||
for state in every {
|
||||
match state {
|
||||
AgentState::Up | AgentState::Offline => {}
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::to_string(&state).expect("serialises"),
|
||||
format!("\"{}\"", state.as_str())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue