1use std::{sync::Arc, time::Duration};
2
3use tokio::sync::mpsc;
4use tokio_util::sync::CancellationToken;
5use uuid::Uuid;
6use wowlab_engine_ports::{ContentCatalog, DataResolver, DynDataResolver};
7
8use crate::{
9 ConnectionStatus, NodeState, WorkBatchResult, WorkerPool, claim, config::NodeConfig,
10 realtime::RealtimeEvent, sentinel::SentinelClient, utils::backoff::ExponentialBackoff,
11 work_context::WorkContextCache,
12};
13
14mod connection;
15mod lifecycle;
16mod unlink;
17mod work;
18
19pub use unlink::{
20 LocalIdentityOutcome, RemoteUnlinkOutcome, UnlinkError, UnlinkErrorCategory, UnlinkOutcome,
21};
22
23const EVENT_CHANNEL_SIZE: usize = 32;
24const INITIAL_BACKOFF_SECS: u64 = 5;
25const MAX_BACKOFF_SECS: u64 = 300;
26
27fn validated_capacity(total_cores: i32, max_parallel: i32) -> Option<(u32, usize)> {
28 let total_cores = u32::try_from(total_cores).ok().filter(|cores| *cores > 0)?;
29 let max_parallel = u32::try_from(max_parallel)
30 .ok()
31 .filter(|workers| *workers > 0)?;
32 let max_parallel = usize::try_from(total_cores.min(max_parallel)).ok()?;
33
34 Some((total_cores, max_parallel))
35}
36
37#[derive(Debug)]
39pub enum NodeCoreEvent {
41 StateChanged(NodeState),
42 ConnectionChanged(ConnectionStatus),
43 ChunkAssigned {
44 job_id: Uuid,
45 chunk_index: i32,
46 iterations: i32,
47 },
48 ChunkCompleted {
49 job_id: Uuid,
50 chunk_index: i32,
51 mean_dps: f32,
52 },
53 ChunkFailed {
54 job_id: Uuid,
55 chunk_index: i32,
56 error: String,
57 },
58 Error(String),
59 UnlinkCompleted(Result<UnlinkOutcome, UnlinkError>),
60}
61
62#[derive(Debug)]
64pub struct NodeEvents {
65 receiver: mpsc::Receiver<NodeCoreEvent>,
66 unlink_receiver: mpsc::UnboundedReceiver<NodeCoreEvent>,
67}
68
69impl NodeEvents {
70 fn new(
71 receiver: mpsc::Receiver<NodeCoreEvent>,
72 unlink_receiver: mpsc::UnboundedReceiver<NodeCoreEvent>,
73 ) -> Self {
74 Self {
75 receiver,
76 unlink_receiver,
77 }
78 }
79
80 pub fn try_recv(&mut self) -> Option<NodeCoreEvent> {
81 self.unlink_receiver
82 .try_recv()
83 .ok()
84 .or_else(|| self.receiver.try_recv().ok())
85 }
86}
87
88enum RegisterResult {
89 Success,
90 Failed(String),
91}
92
93type VerifyReceiver =
94 mpsc::Receiver<Result<wowlab_types::sensitive::Sensitive<String>, VerifyError>>;
95
96#[derive(Debug, thiserror::Error)]
97#[non_exhaustive]
98pub(crate) enum VerifyError {
99 #[error("node not found")]
100 NotFound,
101 #[error("server unavailable")]
102 Unavailable,
103}
104
105#[derive(Clone, Debug)]
107pub struct NodeApplication {
109 name: String,
110 version: String,
111}
112
113impl NodeApplication {
114 #[must_use]
115 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
116 Self {
117 name: name.into(),
118 version: version.into(),
119 }
120 }
121}
122
123pub struct NodeCore {
125 runtime: tokio::runtime::Runtime,
126 config: NodeConfig,
127 sentinel: SentinelClient,
128 worker_pool: WorkerPool,
129 state: NodeState,
130 connection_status: ConnectionStatus,
131 registered: bool,
132 node_name: String,
133 total_cores: u32,
134
135 app_name: String,
136 app_version: String,
137
138 work_context_cache: WorkContextCache,
139
140 verify_rx: Option<VerifyReceiver>,
141 register_rx: Option<mpsc::Receiver<RegisterResult>>,
142 realtime_rx: Option<mpsc::Receiver<RealtimeEvent>>,
143 realtime_shutdown: Option<CancellationToken>,
144 result_rx: Option<mpsc::Receiver<WorkBatchResult>>,
145 unlink_rx: Option<unlink::UnlinkReceiver>,
146 unlink_cancel: Option<CancellationToken>,
147 unlink_origin: Option<unlink::UnlinkOrigin>,
148
149 backoff: ExponentialBackoff,
150
151 event_tx: mpsc::Sender<NodeCoreEvent>,
152 unlink_event_tx: mpsc::UnboundedSender<NodeCoreEvent>,
153
154 started: bool,
155}
156
157impl NodeCore {
158 pub fn with_resolver<R>(
164 config: NodeConfig,
165 resolver: R,
166 application: NodeApplication,
167 catalog: &'static ContentCatalog,
168 ) -> Result<(Self, NodeEvents), crate::sentinel::SentinelError>
169 where
170 R: DataResolver + Send + Sync + 'static,
171 {
172 let runtime = tokio::runtime::Runtime::new()
173 .map_err(|error| crate::sentinel::SentinelError::client_build(error.to_string()))?;
174 let resolver = DynDataResolver::new_arc(resolver);
175
176 Self::new(runtime, resolver, config, application, catalog)
177 }
178
179 #[cfg(feature = "supabase")]
185 pub fn with_supabase(
186 config: NodeConfig,
187 application: NodeApplication,
188 catalog: &'static ContentCatalog,
189 ) -> Result<(Self, NodeEvents), crate::sentinel::SentinelError> {
190 use wowlab_engine_adapter_data::{GameDataCache, SupabaseResolver};
191 use wowlab_supabase::SupabaseClient;
192
193 #[derive(serde::Deserialize)]
194 struct MetaRow {
195 patch_version: String,
196 }
197
198 let runtime = tokio::runtime::Runtime::new()
199 .map_err(|error| crate::sentinel::SentinelError::client_build(error.to_string()))?;
200 let engine_client = SupabaseClient::new(&config.supabase_url, &config.supabase_anon_key)?;
201
202 let meta: Vec<MetaRow> = runtime
203 .block_on(engine_client.get_json("meta?id=eq.1&select=patch_version", "game"))?;
204 let patch_version = meta
205 .into_iter()
206 .next()
207 .map(|m| m.patch_version)
208 .ok_or_else(|| {
209 crate::sentinel::SentinelError::client_build(
210 "game.meta not seeded — run `wowlab snapshot sync` first",
211 )
212 })?;
213
214 let cache_dir = config.game_data_cache_dir.clone().ok_or_else(|| {
215 crate::sentinel::SentinelError::client_build(
216 "game-data cache directory is unavailable; set NODE_CACHE_DIR",
217 )
218 })?;
219 let engine_cache = GameDataCache::new(engine_client, patch_version, cache_dir)?;
220 let resolver: Arc<DynDataResolver<'static>> =
221 DynDataResolver::new_arc(SupabaseResolver::new(engine_cache));
222
223 Self::new(runtime, resolver, config, application, catalog)
224 }
225
226 fn new(
227 runtime: tokio::runtime::Runtime,
228 resolver: Arc<DynDataResolver<'static>>,
229 config: NodeConfig,
230 application: NodeApplication,
231 catalog: &'static ContentCatalog,
232 ) -> Result<(Self, NodeEvents), crate::sentinel::SentinelError> {
233 let mut config = config;
234 let sentinel = SentinelClient::new(config.sentinel_url.clone(), config.clone())?;
235 let (total_cores, enabled_cores) =
236 validated_capacity(config.total_cores, config.enabled_cores).ok_or_else(|| {
237 crate::sentinel::SentinelError::client_build(
238 "NODE_TOTAL_CORES and NODE_ENABLED_CORES must be positive",
239 )
240 })?;
241
242 if let Some(ref token) = config.token_claim {
243 if let Err(msg) = claim::validate_token(token.expose()) {
244 let detail = if msg.is_empty() {
245 "empty token".to_string()
246 } else {
247 msg.to_string()
248 };
249
250 tracing::error!(%detail, "Invalid NODE_CLAIM_TOKEN");
251 config.token_claim = None;
252 }
253 }
254
255 let state = if config.token_claim.is_some() {
256 NodeState::Registering
257 } else {
258 NodeState::Verifying
259 };
260
261 let mut worker_pool = WorkerPool::new(enabled_cores, catalog);
262
263 worker_pool.set_dyn_resolver(resolver);
264
265 let (event_tx, event_rx) = mpsc::channel(EVENT_CHANNEL_SIZE);
266 let (unlink_event_tx, unlink_event_rx) = mpsc::unbounded_channel();
267
268 let core = Self {
269 runtime,
270 state,
271 sentinel,
272 worker_pool,
273 registered: false,
274 node_name: config.name.clone(),
275 total_cores,
276 app_name: application.name,
277 app_version: application.version,
278 work_context_cache: WorkContextCache::new(),
279 connection_status: ConnectionStatus::Connecting,
280 verify_rx: None,
281 register_rx: None,
282 realtime_rx: None,
283 realtime_shutdown: None,
284 result_rx: None,
285 unlink_rx: None,
286 unlink_cancel: None,
287 unlink_origin: None,
288 backoff: ExponentialBackoff::new(
289 Duration::from_secs(INITIAL_BACKOFF_SECS),
290 Duration::from_secs(MAX_BACKOFF_SECS),
291 ),
292 event_tx,
293 unlink_event_tx,
294 config,
295 started: false,
296 };
297
298 Ok((core, NodeEvents::new(event_rx, unlink_event_rx)))
299 }
300}
301
302impl std::fmt::Debug for NodeCore {
303 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304 f.debug_struct("NodeCore")
305 .field("state", &self.state)
306 .field("connection_status", &self.connection_status)
307 .field("registered", &self.registered)
308 .field("node_name", &self.node_name)
309 .field("max_parallel", &self.worker_pool.stats().max_workers)
310 .field("total_cores", &self.total_cores)
311 .field("app_name", &self.app_name)
312 .field("app_version", &self.app_version)
313 .field("started", &self.started)
314 .finish_non_exhaustive()
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use googletest::prelude::*;
321
322 use super::validated_capacity;
323
324 #[gtest]
325 fn capacity_rejects_non_positive_values_and_caps_workers_to_total_cores() -> Result<()> {
326 verify_eq!(validated_capacity(8, 4), Some((8, 4)))?;
327 verify_eq!(validated_capacity(4, 8), Some((4, 4)))?;
328 verify_that!(validated_capacity(0, 4), none())?;
329 verify_that!(validated_capacity(4, 0), none())?;
330 verify_that!(validated_capacity(-1, 4), none())?;
331 verify_that!(validated_capacity(4, -8), none())?;
332
333 verify_that!(validated_capacity(i32::MIN, i32::MIN), none())
334 }
335}