Skip to main content

wowlab_node/
realtime.rs

1//! Realtime subscriptions for node updates and chunk assignments.
2
3use 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    // docref:start realtime-node-subscribe
132    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    // docref:end realtime-node-subscribe
142
143    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}