wowlab_sentinel/
presence.rs1use async_trait::async_trait;
2use wowlab_centrifuge::error_codes;
3use wowlab_common::{NodePublicKey, NodeRealtimeMessage};
4use wowlab_types::sim::FastSet;
5
6use crate::{cron::CronJob, state::ServerState};
7
8crate::db_client!(PresenceDb, "presence", max = 2);
9
10#[derive(Debug)]
12pub(crate) struct PresenceJob {
13 schedule: String,
14}
15
16impl PresenceJob {
17 pub(crate) fn new(schedule: &str) -> Self {
18 Self {
19 schedule: schedule.to_string(),
20 }
21 }
22}
23
24#[async_trait]
25impl CronJob for PresenceJob {
26 fn name(&self) -> &'static str {
27 "presence_sync"
28 }
29
30 fn schedule(&self) -> &str {
31 &self.schedule
32 }
33
34 async fn run(&self, state: &ServerState) {
35 let centrifugo_online: FastSet<NodePublicKey> =
36 match state.presence.get_online("nodes:online").await {
37 Ok(keys) => keys.into_iter().collect(),
38 Err(error) if error.server_code() == Some(error_codes::UNKNOWN_CHANNEL) => {
39 FastSet::default()
40 }
41 Err(e) => {
42 tracing::warn!(error = %e, "Failed to poll presence");
43
44 return;
45 }
46 };
47
48 let db_online: FastSet<NodePublicKey> =
49 match fetch_online_node_keys(state.dbs.get::<PresenceDb>()).await {
50 Ok(keys) => keys.into_iter().collect(),
51 Err(e) => {
52 tracing::warn!(error = %e, "Failed to fetch online nodes from DB");
53
54 return;
55 }
56 };
57
58 tracing::info!(
59 centrifugo = centrifugo_online.len(),
60 db = db_online.len(),
61 "Presence sync"
62 );
63 tracing::debug!(nodes = ?centrifugo_online, "Centrifugo presence snapshot");
64
65 let to_online: Vec<NodePublicKey> =
66 centrifugo_online.difference(&db_online).cloned().collect();
67 let to_offline: Vec<NodePublicKey> =
68 db_online.difference(¢rifugo_online).cloned().collect();
69
70 if !to_online.is_empty() {
71 set_online(state, &to_online).await;
72 }
73
74 if !to_offline.is_empty() {
75 set_offline(state, &to_offline).await;
76 }
77
78 #[expect(
79 clippy::cast_precision_loss,
80 reason = "metrics gauges accept f64 and online node counts remain exactly representable"
81 )]
82 let online_count = centrifugo_online.len() as f64;
83
84 metrics::gauge!(crate::telemetry::NODES_ONLINE).set(online_count);
85 metrics::counter!(crate::telemetry::PRESENCE_POLLS).increment(1);
86 state.touch_presence();
87 }
88}
89
90fn keys_to_strings(keys: &[NodePublicKey]) -> Vec<String> {
91 keys.iter().map(ToString::to_string).collect()
92}
93
94async fn fetch_online_node_keys(db: &sqlx::PgPool) -> Result<Vec<NodePublicKey>, sqlx::Error> {
95 struct Row {
96 public_key: String,
97 }
98 let rows = sqlx::query_file_as!(Row, "queries/presence_fetch_online_keys.sql")
99 .fetch_all(db)
100 .await?;
101
102 Ok(rows
103 .into_iter()
104 .filter_map(|r| r.public_key.parse().ok())
105 .collect())
106}
107
108async fn set_online(state: &ServerState, keys: &[NodePublicKey]) {
109 let keys_str = keys_to_strings(keys);
110 let result = sqlx::query_file!("queries/presence_set_online.sql", &keys_str)
111 .execute(state.dbs.get::<PresenceDb>())
112 .await;
113
114 let Ok(r) = result else {
115 tracing::warn!(error = ?result.unwrap_err(), nodes = ?keys, "Failed to mark nodes online");
116
117 return;
118 };
119
120 if r.rows_affected() == 0 {
121 return;
122 }
123
124 tracing::info!(count = r.rows_affected(), "Nodes online");
125 publish_node_updates(state, keys).await;
126}
127
128async fn set_offline(state: &ServerState, keys: &[NodePublicKey]) {
129 let keys_str = keys_to_strings(keys);
130
131 let result = sqlx::query_file!("queries/presence_set_offline.sql", &keys_str)
132 .execute(state.dbs.get::<PresenceDb>())
133 .await;
134
135 let Ok(r) = result else {
136 tracing::warn!(error = ?result.unwrap_err(), nodes = ?keys, "Failed to mark nodes offline");
137
138 return;
139 };
140
141 if r.rows_affected() == 0 {
142 return;
143 }
144
145 tracing::info!(count = r.rows_affected(), "Nodes offline");
146 metrics::counter!(crate::telemetry::NODES_MARKED_OFFLINE).increment(r.rows_affected());
147 publish_node_updates(state, keys).await;
148}
149
150async fn publish_node_updates(state: &ServerState, keys: &[NodePublicKey]) {
151 let payload = refinement_update(keys);
152
153 state.publish("nodes:all", &payload).await;
154
155 for pk in keys {
156 state.publish(&format!("nodes:{pk}"), &payload).await;
158 }
159}
160
161fn refinement_update(keys: &[NodePublicKey]) -> NodeRealtimeMessage {
162 NodeRealtimeMessage::RefinementUpdated {
163 ids: keys
164 .iter()
165 .map(|key| key.to_string().into_boxed_str())
166 .collect(),
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use googletest::prelude::*;
173
174 use super::*;
175
176 #[gtest]
177 fn publishes_the_shared_refinement_updated_shape() -> Result<()> {
178 let key = NodePublicKey::from_bytes([7; 32]);
179
180 let value =
181 serde_json::to_value(refinement_update(std::slice::from_ref(&key))).or_fail()?;
182
183 verify_eq!(
184 value,
185 serde_json::json!({
186 "type": "refinement_updated",
187 "payload": { "ids": [key.to_string()] }
188 })
189 )?;
190
191 Ok(())
192 }
193}