1use std::time::Duration;
4
5use serde::Deserialize;
6use tokio::{runtime::Handle, sync::mpsc};
7use tokio_util::sync::CancellationToken;
8use wowlab_centrifuge::{Client, ClientConfig, ClientEvent, SubscriptionConfig, SubscriptionEvent};
9use wowlab_common::{NodePublicKey, NodeRealtimeMessage, RuntimeChunkPayload};
10use wowlab_types::sensitive::Sensitive;
11
12use crate::sentinel::SentinelClient;
13
14const CONNECT_TIMEOUT: Duration =
15 Duration::from_secs(wowlab_types::constants::HTTP_CONNECT_TIMEOUT_SECS);
16const EVENT_CHANNEL_SIZE: usize = 32;
17
18#[derive(Clone, Debug)]
19#[non_exhaustive]
20pub(crate) enum RealtimeEvent {
21 NodeUpdated {
22 name: String,
23 total_cores: i32,
24 max_parallel: i32,
25 },
26 ChunkAssigned(RuntimeChunkPayload),
27 Connected,
28 Disconnected,
29 Error(String),
30}
31
32#[derive(Debug)]
33pub(crate) struct NodeRealtime {
34 config: ClientConfig,
35}
36
37pub(crate) struct RealtimeConfig {
38 pub url: String,
39 pub token: Sensitive<String>,
40 pub name: String,
41 pub version: String,
42 pub sentinel: SentinelClient,
43}
44
45fn log_connection_error(error: &str) {
46 tracing::warn!(error, "Realtime connection error");
47}
48
49impl NodeRealtime {
50 pub(crate) fn new(settings: RealtimeConfig) -> Self {
51 let config = ClientConfig::new(settings.url, settings.token)
52 .name(settings.name)
53 .version(settings.version)
54 .get_token(move || {
55 let sentinel = settings.sentinel.clone();
56
57 async move {
58 sentinel
59 .refresh_token()
60 .await
61 .map_err(|error| wowlab_centrifuge::Error::protocol(error.to_string()))
62 }
63 });
64
65 Self { config }
66 }
67
68 pub(crate) fn subscribe(
69 &self,
70 public_key: &NodePublicKey,
71 handle: &Handle,
72 shutdown: CancellationToken,
73 ) -> mpsc::Receiver<RealtimeEvent> {
74 let (tx, rx) = mpsc::channel(EVENT_CHANNEL_SIZE);
75 let config = self.config.clone();
76 let pk_str = public_key.to_string();
77
78 handle.spawn(async move {
79 let client = Client::new(config);
80 let mut events = client.events().await;
81
82 client.connect();
83
84 let connected = tokio::time::timeout(CONNECT_TIMEOUT, async {
85 while let Some(event) = events.recv().await {
86 if matches!(event, ClientEvent::Connected(_)) {
87 return true;
88 }
89
90 if let ClientEvent::Error(error) = event {
91 log_connection_error(&error);
92 }
93
94 tokio::task::yield_now().await;
95 }
96
97 false
98 })
99 .await;
100
101 if connected != Ok(true) {
102 let _ = tx
103 .send(RealtimeEvent::Error("Connection timeout".into()))
104 .await;
105
106 client.disconnect();
107
108 return;
109 }
110
111 if let Err(e) = run_subscriptions(&client, &pk_str, &tx, shutdown).await {
112 let _ = tx.send(RealtimeEvent::Error(e.to_string())).await;
113 }
114
115 let _ = tx.send(RealtimeEvent::Disconnected).await;
116
117 client.disconnect();
118 tracing::debug!("Realtime subscription shut down");
119 });
120
121 rx
122 }
123}
124
125async fn run_subscriptions(
126 client: &Client,
127 public_key: &str,
128 tx: &mpsc::Sender<RealtimeEvent>,
129 shutdown: CancellationToken,
130) -> Result<(), wowlab_centrifuge::Error> {
131 let mut node_sub = client
133 .subscribe(SubscriptionConfig::new(format!("nodes:{public_key}")))
134 .await?;
135 let mut chunks_sub = client
136 .subscribe(SubscriptionConfig::new(format!("chunks:{public_key}")))
137 .await?;
138 let mut presence_sub = client
139 .subscribe(SubscriptionConfig::new("nodes:online").join_leave(true))
140 .await?;
141 tracing::debug!(public_key, "Subscribed to node realtime channels");
144 let _ = tx.send(RealtimeEvent::Connected).await;
145
146 loop {
147 tokio::select! {
148 () = shutdown.cancelled() => break,
149 Some(event) = node_sub.recv() => {
150 if let Some(event) = extract_node_publication(&event) {
151 let _ = tx.send(event).await;
152 }
153 }
154 Some(event) = chunks_sub.recv() => {
155 if let Some(payload) = extract_publication::<RuntimeChunkPayload>(&event) {
156 let _ = tx.send(RealtimeEvent::ChunkAssigned(payload)).await;
157 }
158 }
159 Some(event) = presence_sub.recv() => {
160 log_presence_event(&event);
161 }
162 else => break,
163 }
164 }
165
166 Ok(())
167}
168
169fn extract_node_publication(event: &SubscriptionEvent) -> Option<RealtimeEvent> {
170 let SubscriptionEvent::Publication(publication) = event else {
171 return None;
172 };
173
174 parse_node_publication(&publication.data)
175}
176
177fn parse_node_publication(data: &[u8]) -> Option<RealtimeEvent> {
178 match parse_json::<NodeRealtimeMessage>(data)? {
179 NodeRealtimeMessage::ConfigurationUpdated {
180 name,
181 total_cores,
182 max_parallel,
183 } => Some(RealtimeEvent::NodeUpdated {
184 name,
185 total_cores,
186 max_parallel,
187 }),
188 NodeRealtimeMessage::RefinementUpdated { ids } => {
189 tracing::debug!(?ids, "Ignoring irrelevant node refinement update");
190
191 None
192 }
193 _ => {
194 tracing::debug!("Ignoring irrelevant node realtime message");
195
196 None
197 }
198 }
199}
200
201fn extract_publication<T>(event: &SubscriptionEvent) -> Option<T>
202where
203 T: for<'de> Deserialize<'de>,
204{
205 let SubscriptionEvent::Publication(pub_) = event else {
206 return None;
207 };
208
209 parse_json(&pub_.data)
210}
211
212fn log_presence_event(event: &SubscriptionEvent) {
213 match event {
214 SubscriptionEvent::Join(info) => {
215 tracing::debug!(user = %info.user, client = %info.client, "Node joined");
216 }
217 SubscriptionEvent::Leave(info) => {
218 tracing::debug!(user = %info.user, client = %info.client, "Node left");
219 }
220 _ => {}
221 }
222}
223
224fn parse_json<T>(data: &[u8]) -> Option<T>
225where
226 T: for<'de> Deserialize<'de>,
227{
228 serde_json::from_slice(data)
229 .inspect_err(|e| {
230 tracing::warn!(
231 error = %e,
232 payload_bytes = data.len(),
233 "Failed to parse publication"
234 );
235 })
236 .ok()
237}
238
239#[cfg(test)]
240mod tests {
241 use googletest::prelude::*;
242 use wowlab_common::{RuntimeChunkId, RuntimeWorkFidelity, RuntimeWorkItemKind};
243
244 use super::*;
245
246 #[derive(Clone, Debug, Eq, PartialEq)]
247 struct ObservedNodeState {
248 name: String,
249 total_cores: u32,
250 max_parallel: u32,
251 }
252
253 fn apply_node_publication(data: &[u8], state: &mut ObservedNodeState) -> bool {
254 let Some(RealtimeEvent::NodeUpdated {
255 name,
256 total_cores,
257 max_parallel,
258 }) = parse_node_publication(data)
259 else {
260 return false;
261 };
262
263 state.name = name;
264 state.total_cores = total_cores.unsigned_abs();
265 state.max_parallel = max_parallel.unsigned_abs();
266
267 true
268 }
269
270 fn configured_state() -> ObservedNodeState {
271 ObservedNodeState {
272 name: "Configured Worker".to_string(),
273 total_cores: 16,
274 max_parallel: 8,
275 }
276 }
277
278 fn valid_configuration() -> &'static [u8] {
279 br#"{
280 "type": "configuration_updated",
281 "payload": {
282 "name": "Updated Worker",
283 "totalCores": 32,
284 "maxParallel": 12
285 }
286 }"#
287 }
288
289 #[gtest]
290 fn parses_runtime_chunk_payload_from_centrifuge_json() -> Result<()> {
291 let json = br#"{
292 "jobId": "job-abc",
293 "chunkId": 42,
294 "workContextHash": "ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34",
295 "claimToken": "claim-1",
296 "workItems": [
297 {
298 "itemId": 1,
299 "kind": { "type": "tournament", "permIdx": 9 },
300 "tag": 9,
301 "iterations": 2000,
302 "seedOffset": 18000,
303 "fidelity": "dpsOnly"
304 },
305 {
306 "itemId": 2,
307 "kind": { "type": "base" },
308 "tag": 0,
309 "iterations": 10000,
310 "seedOffset": 0,
311 "fidelity": "full"
312 }
313 ]
314 }"#;
315
316 let payload: RuntimeChunkPayload = parse_json(json).or_fail()?;
317
318 verify_that!(payload.job_id, eq("job-abc"))?;
319 verify_that!(payload.chunk_id, eq(RuntimeChunkId::new(42)))?;
320 verify_that!(payload.claim_token.as_str(), eq("claim-1"))?;
321 verify_that!(payload.work_items, len(eq(2)))?;
322
323 let first = &payload.work_items[0];
324
325 verify_true!(matches!(
326 first.kind,
327 RuntimeWorkItemKind::Tournament { perm_idx: 9 }
328 ))?;
329 verify_that!(first.fidelity, eq(RuntimeWorkFidelity::DpsOnly))?;
330
331 let second = &payload.work_items[1];
332
333 verify_true!(matches!(second.kind, RuntimeWorkItemKind::Base))?;
334
335 verify_that!(second.fidelity, eq(RuntimeWorkFidelity::Full))
336 }
337
338 #[gtest]
339 fn rejects_malformed_chunk_payload() -> Result<()> {
340 let json = br#"{ "jobId": "x" }"#;
341 let parsed: Option<RuntimeChunkPayload> = parse_json(json);
342
343 verify_that!(parsed, none())
344 }
345
346 #[gtest]
347 fn rejects_sentinel_refinement_payload_as_node_configuration() -> Result<()> {
348 let json = br#"{
349 "type": "updated",
350 "payload": { "ids": ["node-public-key"] }
351 }"#;
352
353 let parsed = parse_node_publication(json);
354
355 verify_that!(parsed, none())
356 }
357
358 #[gtest]
359 fn irrelevant_variant_preserves_state_and_next_publication_is_processed() -> Result<()> {
360 let refinement = br#"{
361 "type": "refinement_updated",
362 "payload": { "ids": ["node-public-key"] }
363 }"#;
364 let mut state = configured_state();
365 let before = state.clone();
366
367 verify_false!(apply_node_publication(refinement, &mut state))?;
368 verify_that!(state, eq(&before))?;
369 verify_true!(apply_node_publication(valid_configuration(), &mut state))?;
370
371 verify_that!(state.name, eq("Updated Worker"))
372 }
373
374 #[gtest]
375 fn malformed_payload_preserves_state_and_next_publication_is_processed() -> Result<()> {
376 let malformed = br#"{
377 "type": "configuration_updated",
378 "payload": { "name": "Incomplete Worker" }
379 }"#;
380 let mut state = configured_state();
381 let before = state.clone();
382
383 verify_false!(apply_node_publication(malformed, &mut state))?;
384 verify_that!(state, eq(&before))?;
385 verify_true!(apply_node_publication(valid_configuration(), &mut state))?;
386
387 verify_that!(state.name, eq("Updated Worker"))
388 }
389}