use std::collections::HashMap;
use async_nats::jetstream;
use futures::StreamExt;
use kanade_shared::kv::{BUCKET_AGENT_GROUPS, BUCKET_AGENT_GROUPS_DERIVED};
use kanade_shared::subject;
use kanade_shared::wire::AgentGroups;
use tokio::task::JoinHandle;
use tracing::{info, warn};
use crate::commands;
use crate::nats_retry;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SubscriptionDelta {
pub to_subscribe: Vec<String>,
pub to_unsubscribe: Vec<String>,
}
impl SubscriptionDelta {
pub fn is_empty(&self) -> bool {
self.to_subscribe.is_empty() && self.to_unsubscribe.is_empty()
}
}
pub fn spawn(
client: async_nats::Client,
pc_id: String,
dedup: std::sync::Arc<tokio::sync::Mutex<crate::commands::DedupCache>>,
staleness: crate::staleness::Tracker,
script_cache: crate::script_cache::ScriptCache,
check_sink: crate::check_cache::CheckSink,
verifier: std::sync::Arc<crate::command_verify::Verifier>,
) -> (
tokio::sync::watch::Receiver<Vec<String>>,
tokio::task::JoinHandle<()>,
) {
let (tx, rx) = tokio::sync::watch::channel(Vec::<String>::new());
let handle = tokio::spawn(async move {
manage(
client,
pc_id,
dedup,
staleness,
script_cache,
check_sink,
tx,
verifier,
)
.await;
});
(rx, handle)
}
#[allow(clippy::too_many_arguments)]
async fn manage(
client: async_nats::Client,
pc_id: String,
dedup: std::sync::Arc<tokio::sync::Mutex<crate::commands::DedupCache>>,
staleness: crate::staleness::Tracker,
script_cache: crate::script_cache::ScriptCache,
check_sink: crate::check_cache::CheckSink,
groups_tx: tokio::sync::watch::Sender<Vec<String>>,
verifier: std::sync::Arc<crate::command_verify::Verifier>,
) {
let js = jetstream::new(client.clone());
let mut subs: HashMap<String, JoinHandle<()>> = HashMap::new();
loop {
let manual_kv = nats_retry::wait_for_kv(
&js,
&client,
&staleness,
BUCKET_AGENT_GROUPS,
"agent_groups",
)
.await;
let derived_kv = nats_retry::wait_for_kv(
&js,
&client,
&staleness,
BUCKET_AGENT_GROUPS_DERIVED,
"agent_groups_derived",
)
.await;
let Some(mut manual) = prime(&manual_kv, &pc_id, "agent_groups").await else {
nats_retry::reopen_pause().await;
continue;
};
let Some(mut derived) = prime(&derived_kv, &pc_id, "agent_groups_derived").await else {
nats_retry::reopen_pause().await;
continue;
};
reconcile_and_publish(
&mut subs,
&manual,
&derived,
&client,
&pc_id,
&dedup,
&staleness,
&script_cache,
&check_sink,
&groups_tx,
&verifier,
"prime",
)
.await;
let mut manual_watch = match manual_kv.watch(&pc_id).await {
Ok(w) => w,
Err(e) => {
warn!(error = %e, "watch agent_groups KV key failed; reopening");
nats_retry::reopen_pause().await;
continue;
}
};
let mut derived_watch = match derived_kv.watch(&pc_id).await {
Ok(w) => w,
Err(e) => {
warn!(error = %e, "watch agent_groups_derived KV key failed; reopening");
nats_retry::reopen_pause().await;
continue;
}
};
loop {
let changed = tokio::select! {
entry = manual_watch.next() => match entry {
Some(Ok(e)) => { manual = decode_entry(&e); true }
Some(Err(e)) => { warn!(error = %e, "agent_groups watch entry"); false }
None => { warn!("agent_groups watch ended; reopening"); break; }
},
entry = derived_watch.next() => match entry {
Some(Ok(e)) => { derived = decode_entry(&e); true }
Some(Err(e)) => { warn!(error = %e, "agent_groups_derived watch entry"); false }
None => { warn!("agent_groups_derived watch ended; reopening"); break; }
},
};
if changed {
reconcile_and_publish(
&mut subs,
&manual,
&derived,
&client,
&pc_id,
&dedup,
&staleness,
&script_cache,
&check_sink,
&groups_tx,
&verifier,
"update",
)
.await;
}
}
nats_retry::reopen_pause().await;
}
}
async fn prime(kv: &jetstream::kv::Store, pc_id: &str, bucket: &str) -> Option<Vec<String>> {
match kv.get(pc_id).await {
Ok(Some(bytes)) => Some(parse_groups(&bytes)),
Ok(None) => Some(Vec::new()),
Err(e) => {
warn!(error = %e, bucket, "initial membership KV read failed; pausing and reopening");
None
}
}
}
fn decode_entry(entry: &jetstream::kv::Entry) -> Vec<String> {
use async_nats::jetstream::kv::Operation;
match entry.operation {
Operation::Put => parse_groups(&entry.value),
Operation::Delete | Operation::Purge => Vec::new(),
}
}
pub fn union_groups(manual: &[String], derived: &[String]) -> Vec<String> {
let mut v: Vec<String> = manual.iter().chain(derived).cloned().collect();
v.sort();
v.dedup();
v
}
#[allow(clippy::too_many_arguments)]
async fn reconcile_and_publish(
subs: &mut HashMap<String, JoinHandle<()>>,
manual: &[String],
derived: &[String],
client: &async_nats::Client,
pc_id: &str,
dedup: &std::sync::Arc<tokio::sync::Mutex<crate::commands::DedupCache>>,
staleness: &crate::staleness::Tracker,
script_cache: &crate::script_cache::ScriptCache,
check_sink: &crate::check_cache::CheckSink,
groups_tx: &tokio::sync::watch::Sender<Vec<String>>,
verifier: &std::sync::Arc<crate::command_verify::Verifier>,
reason: &str,
) {
let desired = union_groups(manual, derived);
let current: Vec<String> = subs.keys().cloned().collect();
let delta = diff_groups(¤t, &desired);
if !delta.is_empty() {
info!(
add = ?delta.to_subscribe,
drop = ?delta.to_unsubscribe,
reason,
"reconciling group subscriptions (manual ∪ derived)",
);
apply_delta(
&delta,
subs,
client,
pc_id,
dedup,
staleness,
script_cache,
check_sink,
verifier,
)
.await;
}
let _ = groups_tx.send(desired);
}
#[allow(clippy::too_many_arguments)]
async fn apply_delta(
delta: &SubscriptionDelta,
subs: &mut HashMap<String, JoinHandle<()>>,
client: &async_nats::Client,
pc_id: &str,
dedup: &std::sync::Arc<tokio::sync::Mutex<crate::commands::DedupCache>>,
staleness: &crate::staleness::Tracker,
script_cache: &crate::script_cache::ScriptCache,
check_sink: &crate::check_cache::CheckSink,
verifier: &std::sync::Arc<crate::command_verify::Verifier>,
) {
for g in &delta.to_unsubscribe {
if let Some(handle) = subs.remove(g) {
handle.abort();
info!(group = %g, "unsubscribed from group");
}
}
for g in &delta.to_subscribe {
match client.subscribe(subject::commands_group(g)).await {
Ok(sub) => {
let _ = client.flush().await;
let handle = tokio::spawn(commands::command_loop(
client.clone(),
pc_id.to_string(),
dedup.clone(),
staleness.clone(),
sub,
script_cache.clone(),
check_sink.clone(),
verifier.clone(),
));
subs.insert(g.clone(), handle);
info!(group = %g, "subscribed to group");
}
Err(e) => warn!(error = %e, group = %g, "subscribe to group failed"),
}
}
}
pub(crate) fn parse_groups(bytes: &[u8]) -> Vec<String> {
match serde_json::from_slice::<AgentGroups>(bytes) {
Ok(g) => g.groups,
Err(e) => {
warn!(
error = %e,
bytes = bytes.len(),
"agent_groups value did not parse as AgentGroups JSON; treating as empty"
);
Vec::new()
}
}
}
pub fn diff_groups<S: AsRef<str>, T: AsRef<str>>(
current: &[S],
desired: &[T],
) -> SubscriptionDelta {
use std::collections::BTreeSet;
let current_set: BTreeSet<&str> = current.iter().map(AsRef::as_ref).collect();
let desired_set: BTreeSet<&str> = desired.iter().map(AsRef::as_ref).collect();
SubscriptionDelta {
to_subscribe: desired_set
.difference(¤t_set)
.map(|s| (*s).to_string())
.collect(),
to_unsubscribe: current_set
.difference(&desired_set)
.map(|s| (*s).to_string())
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn union_merges_dedups_and_sorts() {
let manual = vec!["pilot-ring".to_string(), "clients".to_string()];
let derived = vec!["clients".to_string(), "win-24h2".to_string()];
assert_eq!(
union_groups(&manual, &derived),
vec!["clients", "pilot-ring", "win-24h2"]
);
}
#[test]
fn union_handles_empty_sides() {
let manual = vec!["a".to_string()];
assert_eq!(union_groups(&manual, &[]), vec!["a"]);
assert_eq!(union_groups(&[], &manual), vec!["a"]);
assert!(union_groups(&[], &[]).is_empty());
}
#[test]
fn no_change_when_sets_match() {
let d = diff_groups::<&str, &str>(&["wave1", "canary"], &["canary", "wave1"]);
assert_eq!(d, SubscriptionDelta::default());
assert!(d.is_empty());
}
#[test]
fn no_change_on_both_empty() {
let d: SubscriptionDelta = diff_groups::<&str, &str>(&[], &[]);
assert!(d.is_empty());
}
#[test]
fn initial_subscribe_when_current_empty() {
let d = diff_groups::<&str, &str>(&[], &["wave1", "canary"]);
assert_eq!(d.to_subscribe, vec!["canary", "wave1"]);
assert!(d.to_unsubscribe.is_empty());
}
#[test]
fn drop_all_when_desired_empty() {
let d = diff_groups::<&str, &str>(&["wave1", "canary"], &[]);
assert!(d.to_subscribe.is_empty());
assert_eq!(d.to_unsubscribe, vec!["canary", "wave1"]);
}
#[test]
fn add_one_keep_rest() {
let d = diff_groups::<&str, &str>(&["canary"], &["canary", "wave1"]);
assert_eq!(d.to_subscribe, vec!["wave1"]);
assert!(d.to_unsubscribe.is_empty());
}
#[test]
fn drop_one_keep_rest() {
let d = diff_groups::<&str, &str>(&["canary", "wave1"], &["canary"]);
assert!(d.to_subscribe.is_empty());
assert_eq!(d.to_unsubscribe, vec!["wave1"]);
}
#[test]
fn full_swap() {
let d = diff_groups::<&str, &str>(&["wave1", "wave2"], &["dept-eng", "canary"]);
assert_eq!(d.to_subscribe, vec!["canary", "dept-eng"]);
assert_eq!(d.to_unsubscribe, vec!["wave1", "wave2"]);
}
#[test]
fn dedups_inputs() {
let d = diff_groups::<&str, &str>(&["canary", "canary"], &["canary", "canary", "wave1"]);
assert_eq!(d.to_subscribe, vec!["wave1"]);
assert!(d.to_unsubscribe.is_empty());
}
#[test]
fn output_is_sorted_regardless_of_input_order() {
let d = diff_groups::<&str, &str>(&[], &["zeta", "alpha", "mu"]);
assert_eq!(d.to_subscribe, vec!["alpha", "mu", "zeta"]);
}
#[test]
fn accepts_string_and_str_inputs() {
let current: Vec<String> = vec!["wave1".into()];
let desired: Vec<&str> = vec!["wave1", "canary"];
let d = diff_groups(¤t, &desired);
assert_eq!(d.to_subscribe, vec!["canary"]);
}
}