1use std::{
2 collections::{BTreeMap, BTreeSet, HashMap},
3 fmt,
4 net::SocketAddr,
5 sync::{Arc, Mutex},
6 time::{Duration, SystemTime},
7};
8
9use datum_cluster::{ClusterConfig, ClusterNode, ClusterState, Member, MemberState, Signal};
10use datum_net::quic::quinn;
11use tokio::{
12 sync::{mpsc, oneshot, watch},
13 task::JoinHandle,
14 time::{Instant, sleep},
15};
16
17use crate::{
18 Agent, AgentConfig, AgentHandle, ClusterJobMetadata, ClusterPlacementHistory,
19 JobRegistryHandle, PlacementSpec as RegistryPlacementSpec,
20 PlacementStrategy as RegistryPlacementStrategy,
21 dcp::{
22 ClientKind, ClusterJobList, ClusterJobNode, ClusterJobStart, ClusterNodeError,
23 ClusterNodeList, ClusterNodeStatus, ClusterViewProvider, CompleteShardingAsk, DcpClient,
24 DcpError, DcpJobFactories, DcpServer, DcpServerConfig, DcpServerHandle,
25 ForwardShardEnvelopes, Hello, PlacementSpec, PlacementStrategy, RememberClusterAssignment,
26 ResponseStatus, ShardAllocation, ShardAllocationTable, ShardEnvelopeBatchResult,
27 ShardPipeClient, ShardPipeFrame, SubmitClusterJob,
28 client::MetricSubscription,
29 proto::MetricSample,
30 server::{
31 cluster_metadata_from_wire, placement_spec_from_wire, wire_cluster_job_start,
32 wire_job_status,
33 },
34 },
35};
36
37pub const AGENT_ROLE: &str = "agent";
39
40pub type ClusterAgentResult<T> = Result<T, ClusterAgentError>;
42
43#[derive(Debug, thiserror::Error)]
45pub enum ClusterAgentError {
46 #[error("invalid cluster-agent config: {0}")]
47 InvalidConfig(String),
48 #[error(transparent)]
49 Agent(#[from] crate::AgentError),
50 #[error(transparent)]
51 Dcp(#[from] DcpError),
52 #[error(transparent)]
53 Cluster(#[from] datum_cluster::ClusterError),
54}
55
56#[derive(Clone)]
58pub enum NodeSessionTransport {
59 TcpLoopback,
61 QuicMtls {
63 server_name: String,
64 client_config: quinn::ClientConfig,
65 },
66}
67
68impl fmt::Debug for NodeSessionTransport {
69 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70 match self {
71 Self::TcpLoopback => formatter.write_str("TcpLoopback"),
72 Self::QuicMtls { server_name, .. } => formatter
73 .debug_struct("QuicMtls")
74 .field("server_name", server_name)
75 .finish_non_exhaustive(),
76 }
77 }
78}
79
80#[derive(Clone, Debug)]
82pub struct NodeSessionConfig {
83 pub agent_role: String,
85 pub transport: NodeSessionTransport,
87 pub reconnect_min_backoff: Duration,
89 pub reconnect_max_backoff: Duration,
91 pub request_timeout: Duration,
93 pub command_buffer: usize,
95}
96
97impl Default for NodeSessionConfig {
98 fn default() -> Self {
99 Self {
100 agent_role: AGENT_ROLE.to_owned(),
101 transport: NodeSessionTransport::TcpLoopback,
102 reconnect_min_backoff: Duration::from_millis(50),
103 reconnect_max_backoff: Duration::from_secs(2),
104 request_timeout: Duration::from_millis(750),
105 command_buffer: 32,
106 }
107 }
108}
109
110#[derive(Clone, Default)]
112pub struct ClusterAgentConfig {
113 pub agent: AgentConfig,
115 pub cluster: ClusterConfig,
117 pub dcp: DcpServerConfig,
119 pub sessions: NodeSessionConfig,
121}
122
123impl fmt::Debug for ClusterAgentConfig {
124 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
125 formatter
126 .debug_struct("ClusterAgentConfig")
127 .field("agent", &self.agent)
128 .field("cluster", &self.cluster)
129 .field("dcp", &"<DcpServerConfig>")
130 .field("sessions", &self.sessions)
131 .finish()
132 }
133}
134
135pub struct ClusterAgent;
138
139impl ClusterAgent {
140 pub async fn start(
141 config: ClusterAgentConfig,
142 factories: DcpJobFactories,
143 ) -> ClusterAgentResult<ClusterAgentHandle> {
144 let mut config = config;
145 validate_cluster_agent_config(&config)?;
146 ensure_agent_role(&mut config.cluster, &config.sessions.agent_role);
147 config.dcp.node_id = config.cluster.node_id.clone();
148
149 let agent = Agent::start_with_config(config.agent.clone())?;
150 let server = DcpServer::from_agent(&agent, factories.clone(), config.dcp.clone());
151 let server_handle = server.start().await?;
152 let agent_addr = advertised_agent_addr(&config.sessions.transport, &server_handle)?;
153 config.cluster.agent_addr = Some(agent_addr);
154
155 let cluster = ClusterNode::start(config.cluster.clone()).await?;
156 let sessions = NodeSessionManagerHandle::start(
157 config.sessions.clone(),
158 cluster.node_id().to_owned(),
159 cluster.state(),
160 )?;
161 let placement = PlacementCoordinatorHandle::start(
162 cluster.node_id().to_owned(),
163 config.sessions.agent_role.clone(),
164 cluster.state(),
165 agent.registry().clone(),
166 sessions.clone(),
167 factories.clone(),
168 config.sessions.request_timeout,
169 )?;
170 let provider = Arc::new(ClusterView {
171 registry: agent.registry().clone(),
172 state: cluster.state(),
173 sessions: sessions.clone(),
174 placement: placement.clone(),
175 self_node: cluster.node_id().to_owned(),
176 agent_role: config.sessions.agent_role.clone(),
177 });
178 server.set_cluster_view(provider);
179
180 Ok(ClusterAgentHandle {
181 agent,
182 cluster,
183 server,
184 server_handle: Some(server_handle),
185 sessions,
186 placement,
187 })
188 }
189}
190
191pub struct ClusterAgentHandle {
193 agent: AgentHandle,
194 cluster: ClusterNode,
195 server: DcpServer,
196 server_handle: Option<DcpServerHandle>,
197 sessions: NodeSessionManagerHandle,
198 placement: PlacementCoordinatorHandle,
199}
200
201impl ClusterAgentHandle {
202 #[must_use]
203 pub fn agent(&self) -> &AgentHandle {
204 &self.agent
205 }
206
207 #[must_use]
208 pub fn cluster(&self) -> &ClusterNode {
209 &self.cluster
210 }
211
212 #[must_use]
213 pub fn sessions(&self) -> &NodeSessionManagerHandle {
214 &self.sessions
215 }
216
217 #[must_use]
218 pub fn server(&self) -> &DcpServer {
219 &self.server
220 }
221
222 #[must_use]
223 pub fn tcp_addr(&self) -> Option<SocketAddr> {
224 self.server_handle
225 .as_ref()
226 .and_then(DcpServerHandle::tcp_addr)
227 }
228
229 #[must_use]
230 pub fn quic_addr(&self) -> Option<SocketAddr> {
231 self.server_handle
232 .as_ref()
233 .and_then(DcpServerHandle::quic_addr)
234 }
235
236 pub async fn shutdown(mut self) -> ClusterAgentResult<()> {
237 let node_id = self.cluster.node_id().to_owned();
238 self.server.clear_cluster_view();
239 self.server.clear_sharding_view();
240 self.placement.shutdown().await;
241 eprintln!(
242 "datum-agent INFO shutdown_component_stopped node_id={node_id} component=placement"
243 );
244 self.sessions.shutdown().await;
245 eprintln!(
246 "datum-agent INFO shutdown_component_stopped node_id={node_id} component=sessions"
247 );
248 let _ = self.cluster.abort().await;
249 eprintln!(
250 "datum-agent INFO shutdown_component_stopped node_id={node_id} component=cluster"
251 );
252 if let Some(handle) = self.server_handle.take() {
253 handle.shutdown().await;
254 }
255 eprintln!(
256 "datum-agent INFO shutdown_component_stopped node_id={node_id} component=dcp_server"
257 );
258 self.agent.registry().shutdown()?;
259 eprintln!(
260 "datum-agent INFO shutdown_component_stopped node_id={node_id} component=registry"
261 );
262 eprintln!("datum-agent INFO shutdown_done node_id={node_id}");
263 Ok(())
264 }
265}
266
267impl Drop for ClusterAgentHandle {
268 fn drop(&mut self) {
269 self.server.clear_cluster_view();
270 self.server.clear_sharding_view();
271 }
272}
273
274#[derive(Clone)]
275pub struct NodeSessionManagerHandle {
276 inner: Arc<NodeSessionManagerInner>,
277}
278
279struct NodeSessionManagerInner {
280 config: NodeSessionConfig,
281 self_node: String,
282 state: Signal<ClusterState>,
283 sessions: tokio::sync::Mutex<BTreeMap<String, PeerSession>>,
284 tasks: Mutex<Vec<JoinHandle<()>>>,
285}
286
287struct PeerSession {
288 member: Member,
289 commands: mpsc::Sender<SessionCommand>,
290 pipe_commands: mpsc::Sender<ShardPipeCommand>,
291 stop: watch::Sender<bool>,
292 state: watch::Receiver<PeerSessionState>,
293 task: JoinHandle<()>,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq)]
297enum PeerSessionState {
298 Connecting,
299 Connected,
300 BackingOff(String),
301 Closed,
302}
303
304impl PeerSessionState {
305 fn as_text(&self) -> String {
306 match self {
307 Self::Connecting => "connecting".to_owned(),
308 Self::Connected => "connected".to_owned(),
309 Self::BackingOff(error) => format!("backing_off:{error}"),
310 Self::Closed => "closed".to_owned(),
311 }
312 }
313}
314
315enum SessionCommand {
316 ListJobs {
317 timeout: Duration,
318 reply: oneshot::Sender<Result<Vec<crate::dcp::proto::JobStatus>, String>>,
319 },
320 SubmitClusterJob {
321 request: SubmitClusterJob,
322 timeout: Duration,
323 reply: oneshot::Sender<Result<crate::dcp::proto::JobStatus, String>>,
324 },
325 StartClusterJob {
326 factory_name: String,
327 instance_name: String,
328 params: HashMap<String, String>,
329 assignment: ClusterJobStart,
330 timeout: Duration,
331 reply: oneshot::Sender<Result<crate::dcp::proto::JobStatus, String>>,
332 },
333 ClusterJobStatus {
334 name: String,
335 cluster: bool,
336 timeout: Duration,
337 reply: oneshot::Sender<Result<crate::dcp::proto::JobStatus, String>>,
338 },
339 DrainClusterJob {
340 name: String,
341 cluster: bool,
342 timeout: Duration,
343 reply: oneshot::Sender<Result<crate::dcp::proto::JobStatus, String>>,
344 },
345 StopClusterJob {
346 name: String,
347 cluster: bool,
348 timeout: Duration,
349 reply: oneshot::Sender<Result<crate::dcp::proto::JobStatus, String>>,
350 },
351 ListClusterJobs {
352 timeout: Duration,
353 reply: oneshot::Sender<Result<ClusterJobList, String>>,
354 },
355 SubscribeMetrics {
356 interval: Duration,
357 job_names: Vec<String>,
358 timeout: Duration,
359 reply: oneshot::Sender<Result<MetricSubscription, String>>,
360 },
361 RememberClusterAssignment {
362 instance_name: String,
363 assignment: Option<ClusterJobStart>,
364 timeout: Duration,
365 reply: oneshot::Sender<Result<(), String>>,
366 },
367 AllocateShard {
368 type_name: String,
369 shard_id: String,
370 timeout: Duration,
371 reply: oneshot::Sender<Result<ShardAllocation, String>>,
372 },
373 RememberShardAllocations {
374 table: ShardAllocationTable,
375 timeout: Duration,
376 reply: oneshot::Sender<Result<(), String>>,
377 },
378 GetShardAllocations {
379 type_name: String,
380 timeout: Duration,
381 reply: oneshot::Sender<Result<ShardAllocationTable, String>>,
382 },
383 ForwardShardEnvelopes {
384 batch: ForwardShardEnvelopes,
385 timeout: Duration,
386 reply: oneshot::Sender<Result<ShardEnvelopeBatchResult, String>>,
387 },
388 CompleteShardingAsk {
389 response: CompleteShardingAsk,
390 timeout: Duration,
391 reply: oneshot::Sender<Result<(), String>>,
392 },
393}
394
395enum ShardPipeCommand {
396 Forward { batch: ForwardShardEnvelopes },
397 Reply { response: CompleteShardingAsk },
398}
399
400impl NodeSessionManagerHandle {
401 fn start(
402 config: NodeSessionConfig,
403 self_node: String,
404 state: Signal<ClusterState>,
405 ) -> ClusterAgentResult<Self> {
406 let inner = Arc::new(NodeSessionManagerInner {
407 config,
408 self_node,
409 state,
410 sessions: tokio::sync::Mutex::new(BTreeMap::new()),
411 tasks: Mutex::new(Vec::new()),
412 });
413
414 let manager_task = {
415 let inner = Arc::clone(&inner);
416 tokio::spawn(async move {
417 run_node_session_manager(inner).await;
418 })
419 };
420 {
421 let mut tasks = inner.tasks.lock().expect("node-session tasks poisoned");
422 tasks.push(manager_task);
423 }
424
425 Ok(Self { inner })
426 }
427
428 pub async fn shutdown(&self) {
429 let sessions = {
430 let mut locked = self.inner.sessions.lock().await;
431 std::mem::take(&mut *locked)
432 };
433 for (_, session) in sessions {
434 let _ = session.stop.send(true);
435 session.task.abort();
436 }
437 let tasks = {
438 let mut locked = self
439 .inner
440 .tasks
441 .lock()
442 .expect("node-session tasks poisoned");
443 std::mem::take(&mut *locked)
444 };
445 for task in tasks {
446 task.abort();
447 }
448 }
449
450 async fn list_jobs(
451 &self,
452 node_id: &str,
453 timeout: Duration,
454 ) -> Result<Vec<crate::dcp::proto::JobStatus>, String> {
455 let sender = {
456 let locked = self.inner.sessions.lock().await;
457 locked.get(node_id).map(|session| session.commands.clone())
458 }
459 .ok_or_else(|| "node session unavailable".to_owned())?;
460 let (reply, receiver) = oneshot::channel();
461 sender
462 .try_send(SessionCommand::ListJobs { timeout, reply })
463 .map_err(|error| format!("node session command queue unavailable: {error}"))?;
464 tokio::time::timeout(timeout, receiver)
465 .await
466 .map_err(|_| "node session request timed out".to_owned())?
467 .map_err(|_| "node session closed".to_owned())?
468 }
469
470 async fn submit_cluster_job(
471 &self,
472 node_id: &str,
473 request: SubmitClusterJob,
474 timeout: Duration,
475 ) -> Result<crate::dcp::proto::JobStatus, String> {
476 self.request_status(node_id, timeout, |reply| SessionCommand::SubmitClusterJob {
477 request,
478 timeout,
479 reply,
480 })
481 .await
482 }
483
484 async fn start_cluster_job(
485 &self,
486 node_id: &str,
487 factory_name: String,
488 instance_name: String,
489 params: HashMap<String, String>,
490 assignment: ClusterJobStart,
491 timeout: Duration,
492 ) -> Result<crate::dcp::proto::JobStatus, String> {
493 self.request_status(node_id, timeout, |reply| SessionCommand::StartClusterJob {
494 factory_name,
495 instance_name,
496 params,
497 assignment,
498 timeout,
499 reply,
500 })
501 .await
502 }
503
504 async fn cluster_job_status(
505 &self,
506 node_id: &str,
507 name: String,
508 timeout: Duration,
509 ) -> Result<crate::dcp::proto::JobStatus, String> {
510 self.request_status(node_id, timeout, |reply| SessionCommand::ClusterJobStatus {
511 name,
512 cluster: false,
513 timeout,
514 reply,
515 })
516 .await
517 }
518
519 async fn drain_cluster_job(
520 &self,
521 node_id: &str,
522 name: String,
523 timeout: Duration,
524 ) -> Result<crate::dcp::proto::JobStatus, String> {
525 self.request_status(node_id, timeout, |reply| SessionCommand::DrainClusterJob {
526 name,
527 cluster: false,
528 timeout,
529 reply,
530 })
531 .await
532 }
533
534 async fn stop_cluster_job(
535 &self,
536 node_id: &str,
537 name: String,
538 timeout: Duration,
539 ) -> Result<crate::dcp::proto::JobStatus, String> {
540 self.request_status(node_id, timeout, |reply| SessionCommand::StopClusterJob {
541 name,
542 cluster: false,
543 timeout,
544 reply,
545 })
546 .await
547 }
548
549 async fn forward_cluster_job_status(
550 &self,
551 node_id: &str,
552 name: String,
553 timeout: Duration,
554 ) -> Result<crate::dcp::proto::JobStatus, String> {
555 self.request_status(node_id, timeout, |reply| SessionCommand::ClusterJobStatus {
556 name,
557 cluster: true,
558 timeout,
559 reply,
560 })
561 .await
562 }
563
564 async fn forward_drain_cluster_job(
565 &self,
566 node_id: &str,
567 name: String,
568 timeout: Duration,
569 ) -> Result<crate::dcp::proto::JobStatus, String> {
570 self.request_status(node_id, timeout, |reply| SessionCommand::DrainClusterJob {
571 name,
572 cluster: true,
573 timeout,
574 reply,
575 })
576 .await
577 }
578
579 async fn forward_stop_cluster_job(
580 &self,
581 node_id: &str,
582 name: String,
583 timeout: Duration,
584 ) -> Result<crate::dcp::proto::JobStatus, String> {
585 self.request_status(node_id, timeout, |reply| SessionCommand::StopClusterJob {
586 name,
587 cluster: true,
588 timeout,
589 reply,
590 })
591 .await
592 }
593
594 async fn list_cluster_jobs(
595 &self,
596 node_id: &str,
597 timeout: Duration,
598 ) -> Result<ClusterJobList, String> {
599 let sender = self.session_sender(node_id).await?;
600 let (reply, receiver) = oneshot::channel();
601 sender
602 .try_send(SessionCommand::ListClusterJobs { timeout, reply })
603 .map_err(|error| format!("node session command queue unavailable: {error}"))?;
604 wait_session_reply(timeout, receiver).await
605 }
606
607 async fn subscribe_metrics(
608 &self,
609 node_id: &str,
610 interval: Duration,
611 job_names: Vec<String>,
612 timeout: Duration,
613 ) -> Result<MetricSubscription, String> {
614 let sender = self.session_sender(node_id).await?;
615 let (reply, receiver) = oneshot::channel();
616 sender
617 .try_send(SessionCommand::SubscribeMetrics {
618 interval,
619 job_names,
620 timeout,
621 reply,
622 })
623 .map_err(|error| format!("node session command queue unavailable: {error}"))?;
624 wait_session_reply(timeout, receiver).await
625 }
626
627 async fn remember_cluster_assignment(
628 &self,
629 node_id: &str,
630 instance_name: String,
631 assignment: Option<ClusterJobStart>,
632 timeout: Duration,
633 ) -> Result<(), String> {
634 let sender = self.session_sender(node_id).await?;
635 let (reply, receiver) = oneshot::channel();
636 sender
637 .try_send(SessionCommand::RememberClusterAssignment {
638 instance_name,
639 assignment,
640 timeout,
641 reply,
642 })
643 .map_err(|error| format!("node session command queue unavailable: {error}"))?;
644 wait_session_reply(timeout, receiver).await
645 }
646
647 pub async fn allocate_shard(
648 &self,
649 node_id: &str,
650 type_name: String,
651 shard_id: String,
652 timeout: Duration,
653 ) -> Result<ShardAllocation, String> {
654 let sender = self.session_sender(node_id).await?;
655 let (reply, receiver) = oneshot::channel();
656 sender
657 .try_send(SessionCommand::AllocateShard {
658 type_name,
659 shard_id,
660 timeout,
661 reply,
662 })
663 .map_err(|error| format!("node session command queue unavailable: {error}"))?;
664 wait_session_reply(timeout, receiver).await
665 }
666
667 pub async fn remember_shard_allocations(
668 &self,
669 node_id: &str,
670 table: ShardAllocationTable,
671 timeout: Duration,
672 ) -> Result<(), String> {
673 let sender = self.session_sender(node_id).await?;
674 let (reply, receiver) = oneshot::channel();
675 sender
676 .try_send(SessionCommand::RememberShardAllocations {
677 table,
678 timeout,
679 reply,
680 })
681 .map_err(|error| format!("node session command queue unavailable: {error}"))?;
682 wait_session_reply(timeout, receiver).await
683 }
684
685 pub async fn get_shard_allocations(
686 &self,
687 node_id: &str,
688 type_name: String,
689 timeout: Duration,
690 ) -> Result<ShardAllocationTable, String> {
691 let sender = self.session_sender(node_id).await?;
692 let (reply, receiver) = oneshot::channel();
693 sender
694 .try_send(SessionCommand::GetShardAllocations {
695 type_name,
696 timeout,
697 reply,
698 })
699 .map_err(|error| format!("node session command queue unavailable: {error}"))?;
700 wait_session_reply(timeout, receiver).await
701 }
702
703 pub async fn forward_shard_envelopes(
704 &self,
705 node_id: &str,
706 batch: ForwardShardEnvelopes,
707 timeout: Duration,
708 ) -> Result<ShardEnvelopeBatchResult, String> {
709 let sender = self.session_sender(node_id).await?;
710 let (reply, receiver) = oneshot::channel();
711 sender
712 .try_send(SessionCommand::ForwardShardEnvelopes {
713 batch,
714 timeout,
715 reply,
716 })
717 .map_err(|error| format!("node session command queue unavailable: {error}"))?;
718 wait_session_reply(timeout, receiver).await
719 }
720
721 pub async fn complete_sharding_ask(
722 &self,
723 node_id: &str,
724 response: CompleteShardingAsk,
725 timeout: Duration,
726 ) -> Result<(), String> {
727 let sender = self.session_sender(node_id).await?;
728 let (reply, receiver) = oneshot::channel();
729 sender
730 .try_send(SessionCommand::CompleteShardingAsk {
731 response,
732 timeout,
733 reply,
734 })
735 .map_err(|error| format!("node session command queue unavailable: {error}"))?;
736 wait_session_reply(timeout, receiver).await
737 }
738
739 pub async fn forward_shard_pipe_envelopes(
740 &self,
741 node_id: &str,
742 batch: ForwardShardEnvelopes,
743 ) -> Result<(), String> {
744 let sender = self.pipe_sender(node_id).await?;
745 sender
746 .try_send(ShardPipeCommand::Forward { batch })
747 .map_err(|error| format!("node session shard pipe queue unavailable: {error}"))
748 }
749
750 pub async fn complete_sharding_ask_pipe(
751 &self,
752 node_id: &str,
753 response: CompleteShardingAsk,
754 ) -> Result<(), String> {
755 let sender = self.pipe_sender(node_id).await?;
756 sender
757 .try_send(ShardPipeCommand::Reply { response })
758 .map_err(|error| format!("node session shard pipe queue unavailable: {error}"))
759 }
760
761 pub fn try_complete_sharding_ask_pipe(
762 &self,
763 node_id: &str,
764 response: CompleteShardingAsk,
765 ) -> Result<(), String> {
766 let locked = self
767 .inner
768 .sessions
769 .try_lock()
770 .map_err(|_| "node session table busy".to_owned())?;
771 let sender = locked
772 .get(node_id)
773 .map(|session| session.pipe_commands.clone())
774 .ok_or_else(|| "node session unavailable".to_owned())?;
775 sender
776 .try_send(ShardPipeCommand::Reply { response })
777 .map_err(|error| format!("node session shard pipe queue unavailable: {error}"))
778 }
779
780 async fn request_status<F>(
781 &self,
782 node_id: &str,
783 timeout: Duration,
784 make_command: F,
785 ) -> Result<crate::dcp::proto::JobStatus, String>
786 where
787 F: FnOnce(oneshot::Sender<Result<crate::dcp::proto::JobStatus, String>>) -> SessionCommand,
788 {
789 let sender = self.session_sender(node_id).await?;
790 let (reply, receiver) = oneshot::channel();
791 sender
792 .try_send(make_command(reply))
793 .map_err(|error| format!("node session command queue unavailable: {error}"))?;
794 wait_session_reply(timeout, receiver).await
795 }
796
797 async fn session_sender(&self, node_id: &str) -> Result<mpsc::Sender<SessionCommand>, String> {
798 let locked = self.inner.sessions.lock().await;
799 locked
800 .get(node_id)
801 .map(|session| session.commands.clone())
802 .ok_or_else(|| "node session unavailable".to_owned())
803 }
804
805 async fn pipe_sender(&self, node_id: &str) -> Result<mpsc::Sender<ShardPipeCommand>, String> {
806 let locked = self.inner.sessions.lock().await;
807 locked
808 .get(node_id)
809 .map(|session| session.pipe_commands.clone())
810 .ok_or_else(|| "node session unavailable".to_owned())
811 }
812
813 async fn session_states(&self) -> BTreeMap<String, String> {
814 let locked = self.inner.sessions.lock().await;
815 locked
816 .iter()
817 .map(|(node_id, session)| (node_id.clone(), session.state.borrow().clone().as_text()))
818 .collect()
819 }
820}
821
822async fn wait_session_reply<T>(
823 timeout: Duration,
824 receiver: oneshot::Receiver<Result<T, String>>,
825) -> Result<T, String> {
826 tokio::time::timeout(timeout, receiver)
827 .await
828 .map_err(|_| "node session request timed out".to_owned())?
829 .map_err(|_| "node session closed".to_owned())?
830}
831
832async fn run_node_session_manager(inner: Arc<NodeSessionManagerInner>) {
833 let interval = inner
834 .config
835 .reconnect_min_backoff
836 .min(Duration::from_millis(100))
837 .max(Duration::from_millis(10));
838 loop {
839 reconcile_sessions(&inner).await;
840 tokio::time::sleep(interval).await;
841 }
842}
843
844async fn reconcile_sessions(inner: &Arc<NodeSessionManagerInner>) {
845 let state = inner.state.get();
846 let mut wanted = BTreeSet::new();
847 for member in state.members.values() {
848 if eligible_member(inner, member) {
849 wanted.insert(member.node_id.clone());
850 ensure_session(inner, member.clone()).await;
851 }
852 }
853 close_unwanted_sessions(inner, &wanted).await;
854}
855
856fn eligible_member(inner: &NodeSessionManagerInner, member: &Member) -> bool {
857 member.node_id != inner.self_node
858 && member.state == MemberState::Up
859 && member.has_role(&inner.config.agent_role)
860 && member.agent_addr.is_some()
861}
862
863async fn ensure_session(inner: &Arc<NodeSessionManagerInner>, member: Member) {
864 let mut locked = inner.sessions.lock().await;
865 if let Some(existing) = locked.get(&member.node_id)
866 && existing.member.agent_addr == member.agent_addr
867 && existing.member.incarnation == member.incarnation
868 {
869 return;
870 }
871
872 if let Some(existing) = locked.remove(&member.node_id) {
873 let _ = existing.stop.send(true);
874 existing.task.abort();
875 }
876
877 let (commands, receiver) = mpsc::channel(inner.config.command_buffer.max(1));
878 let pipe_buffer = inner.config.command_buffer.saturating_mul(4).max(1);
879 let (pipe_commands, pipe_receiver) = mpsc::channel(pipe_buffer);
880 let (stop, stop_receiver) = watch::channel(false);
881 let (state_sender, state_receiver) = watch::channel(PeerSessionState::Connecting);
882 let config = inner.config.clone();
883 let local_node = inner.self_node.clone();
884 let task_member = member.clone();
885 let task = tokio::spawn(async move {
886 run_peer_session(
887 config,
888 local_node,
889 task_member,
890 receiver,
891 pipe_receiver,
892 stop_receiver,
893 state_sender,
894 )
895 .await;
896 });
897
898 locked.insert(
899 member.node_id.clone(),
900 PeerSession {
901 member,
902 commands,
903 pipe_commands,
904 stop,
905 state: state_receiver,
906 task,
907 },
908 );
909}
910
911async fn close_unwanted_sessions(inner: &Arc<NodeSessionManagerInner>, wanted: &BTreeSet<String>) {
912 let to_close = {
913 let locked = inner.sessions.lock().await;
914 locked
915 .keys()
916 .filter(|node_id| !wanted.contains(*node_id))
917 .cloned()
918 .collect::<Vec<_>>()
919 };
920 for node_id in to_close {
921 close_session(inner, &node_id).await;
922 }
923}
924
925async fn close_session(inner: &Arc<NodeSessionManagerInner>, node_id: &str) {
926 let removed = {
927 let mut locked = inner.sessions.lock().await;
928 locked.remove(node_id)
929 };
930 if let Some(session) = removed {
931 let _ = session.stop.send(true);
932 session.task.abort();
933 }
934}
935
936async fn run_peer_session(
937 config: NodeSessionConfig,
938 local_node: String,
939 member: Member,
940 mut commands: mpsc::Receiver<SessionCommand>,
941 mut pipe_commands: mpsc::Receiver<ShardPipeCommand>,
942 mut stop: watch::Receiver<bool>,
943 state: watch::Sender<PeerSessionState>,
944) {
945 let mut backoff = config.reconnect_min_backoff;
946 loop {
947 if *stop.borrow() {
948 break;
949 }
950 let _ = state.send(PeerSessionState::Connecting);
951 match connect_peer(&config.transport, &local_node, &member).await {
952 Ok(client) => match connect_shard_pipe(&config.transport, &local_node, &member).await {
953 Ok(pipe) => {
954 let _ = state.send(PeerSessionState::Connected);
955 eprintln!(
956 "datum-agent INFO peer_session_connected node_id={local_node} peer={}",
957 member.node_id
958 );
959 backoff = config.reconnect_min_backoff;
960 if !run_connected_peer(
961 client,
962 pipe,
963 &mut commands,
964 &mut pipe_commands,
965 &mut stop,
966 )
967 .await
968 {
969 break;
970 }
971 eprintln!(
972 "datum-agent WARN peer_session_lost node_id={local_node} peer={}",
973 member.node_id
974 );
975 }
976 Err(error) => {
977 log_peer_connect_failed(&local_node, &member.node_id, &error);
978 let _ = state.send(PeerSessionState::BackingOff(error.clone()));
979 if !backoff_or_stop(
980 backoff,
981 error,
982 &mut commands,
983 &mut pipe_commands,
984 &mut stop,
985 )
986 .await
987 {
988 break;
989 }
990 backoff = next_backoff(backoff, config.reconnect_max_backoff);
991 log_peer_reconnecting(&local_node, &member.node_id, backoff);
992 }
993 },
994 Err(error) => {
995 log_peer_connect_failed(&local_node, &member.node_id, &error);
996 let _ = state.send(PeerSessionState::BackingOff(error.clone()));
997 if !backoff_or_stop(backoff, error, &mut commands, &mut pipe_commands, &mut stop)
998 .await
999 {
1000 break;
1001 }
1002 backoff = next_backoff(backoff, config.reconnect_max_backoff);
1003 log_peer_reconnecting(&local_node, &member.node_id, backoff);
1004 }
1005 }
1006 }
1007 let _ = state.send(PeerSessionState::Closed);
1008 eprintln!(
1009 "datum-agent INFO peer_session_closed node_id={local_node} peer={}",
1010 member.node_id
1011 );
1012}
1013
1014fn log_peer_connect_failed(local_node: &str, peer: &str, error: &str) {
1015 eprintln!(
1016 "datum-agent WARN peer_session_connect_failed node_id={local_node} peer={peer} error={error}"
1017 );
1018}
1019
1020fn log_peer_reconnecting(local_node: &str, peer: &str, backoff: Duration) {
1021 eprintln!(
1022 "datum-agent INFO peer_session_reconnecting node_id={local_node} peer={peer} backoff_ms={}",
1023 backoff.as_millis()
1024 );
1025}
1026
1027async fn connect_peer(
1028 transport: &NodeSessionTransport,
1029 local_node: &str,
1030 member: &Member,
1031) -> Result<DcpClient, String> {
1032 let addr = member
1033 .agent_addr
1034 .ok_or_else(|| "member did not advertise a DCP agent address".to_owned())?;
1035 let mut hello = Hello::new(local_node.to_owned(), ClientKind::ClusterNode);
1036 hello.capabilities.push("cluster-node-sessions".to_owned());
1037 match transport {
1038 NodeSessionTransport::TcpLoopback => {
1039 if !addr.ip().is_loopback() {
1040 return Err(format!(
1041 "plaintext node-session TCP requires loopback, got {addr}"
1042 ));
1043 }
1044 DcpClient::connect_tcp(addr, hello)
1045 .await
1046 .map_err(|error| error.to_string())
1047 }
1048 NodeSessionTransport::QuicMtls {
1049 server_name,
1050 client_config,
1051 } => DcpClient::connect_quic(addr, server_name, client_config.clone(), hello)
1052 .await
1053 .map_err(|error| error.to_string()),
1054 }
1055}
1056
1057async fn connect_shard_pipe(
1058 transport: &NodeSessionTransport,
1059 local_node: &str,
1060 member: &Member,
1061) -> Result<ShardPipeClient, String> {
1062 let addr = member
1063 .agent_addr
1064 .ok_or_else(|| "member did not advertise a DCP agent address".to_owned())?;
1065 let mut hello = Hello::new(local_node.to_owned(), ClientKind::ClusterNode);
1066 hello.capabilities.push("cluster-node-sessions".to_owned());
1067 hello.capabilities.push("cluster-sharding-pipe".to_owned());
1068 match transport {
1069 NodeSessionTransport::TcpLoopback => {
1070 if !addr.ip().is_loopback() {
1071 return Err(format!(
1072 "plaintext node-session TCP requires loopback, got {addr}"
1073 ));
1074 }
1075 ShardPipeClient::connect_tcp(addr, hello)
1076 .await
1077 .map_err(|error| error.to_string())
1078 }
1079 NodeSessionTransport::QuicMtls {
1080 server_name,
1081 client_config,
1082 } => ShardPipeClient::connect_quic(addr, server_name, client_config.clone(), hello)
1083 .await
1084 .map_err(|error| error.to_string()),
1085 }
1086}
1087
1088async fn run_connected_peer(
1089 client: DcpClient,
1090 pipe: ShardPipeClient,
1091 commands: &mut mpsc::Receiver<SessionCommand>,
1092 pipe_commands: &mut mpsc::Receiver<ShardPipeCommand>,
1093 stop: &mut watch::Receiver<bool>,
1094) -> bool {
1095 loop {
1096 tokio::select! {
1097 changed = stop.changed() => {
1098 return changed.is_ok() && !*stop.borrow();
1099 }
1100 command = pipe_commands.recv() => {
1101 let Some(command) = command else {
1102 return false;
1103 };
1104 let frame = build_shard_pipe_frame(command, pipe_commands);
1105 if pipe.send(frame).await.is_err() {
1106 return true;
1107 }
1108 }
1109 command = commands.recv() => {
1110 let Some(command) = command else {
1111 return false;
1112 };
1113 match command {
1114 SessionCommand::ListJobs { timeout, reply } => {
1115 let result = tokio::time::timeout(timeout, client.list_jobs()).await
1116 .map_err(|_| "peer ListJobs timed out".to_owned())
1117 .and_then(|result| result.map_err(|error| error.to_string()));
1118 let reconnect = result.is_err();
1119 let _ = reply.send(result);
1120 if reconnect {
1121 return true;
1122 }
1123 }
1124 SessionCommand::SubmitClusterJob {
1125 request,
1126 timeout,
1127 reply,
1128 } => {
1129 let timeout_ms = duration_millis_u64(timeout);
1130 let result = tokio::time::timeout(
1131 timeout,
1132 client.submit_cluster_job(
1133 request.factory_name,
1134 request.instance_name,
1135 request.params,
1136 request.placement.unwrap_or_else(default_wire_placement),
1137 timeout_ms,
1138 ),
1139 )
1140 .await
1141 .map_err(|_| "peer SubmitClusterJob timed out".to_owned())
1142 .and_then(|result| result.map_err(|error| error.to_string()));
1143 let reconnect = result.is_err();
1144 let _ = reply.send(result);
1145 if reconnect {
1146 return true;
1147 }
1148 }
1149 SessionCommand::StartClusterJob {
1150 factory_name,
1151 instance_name,
1152 params,
1153 assignment,
1154 timeout,
1155 reply,
1156 } => {
1157 let result = tokio::time::timeout(
1158 timeout,
1159 client.start_cluster_job_on_node(
1160 factory_name,
1161 instance_name,
1162 params,
1163 assignment,
1164 ),
1165 )
1166 .await
1167 .map_err(|_| "peer StartJob timed out".to_owned())
1168 .and_then(|result| result.map_err(|error| error.to_string()));
1169 let reconnect = result.is_err();
1170 let _ = reply.send(result);
1171 if reconnect {
1172 return true;
1173 }
1174 }
1175 SessionCommand::ClusterJobStatus {
1176 name,
1177 cluster,
1178 timeout,
1179 reply,
1180 } => {
1181 let request = async {
1182 if cluster {
1183 client
1184 .cluster_job_status(name, duration_millis_u64(timeout))
1185 .await
1186 } else {
1187 client.job_status(name).await
1188 }
1189 };
1190 let result = tokio::time::timeout(timeout, request).await
1191 .map_err(|_| "peer JobStatus timed out".to_owned())
1192 .and_then(|result| result.map_err(|error| error.to_string()));
1193 let reconnect = result.is_err();
1194 let _ = reply.send(result);
1195 if reconnect {
1196 return true;
1197 }
1198 }
1199 SessionCommand::DrainClusterJob {
1200 name,
1201 cluster,
1202 timeout,
1203 reply,
1204 } => {
1205 let request = async {
1206 if cluster {
1207 client
1208 .drain_cluster_job(name, duration_millis_u64(timeout))
1209 .await
1210 } else {
1211 client.drain_job(name).await
1212 }
1213 };
1214 let result = tokio::time::timeout(timeout, request).await
1215 .map_err(|_| "peer DrainJob timed out".to_owned())
1216 .and_then(|result| result.map_err(|error| error.to_string()));
1217 let reconnect = result.is_err();
1218 let _ = reply.send(result);
1219 if reconnect {
1220 return true;
1221 }
1222 }
1223 SessionCommand::StopClusterJob {
1224 name,
1225 cluster,
1226 timeout,
1227 reply,
1228 } => {
1229 let request = async {
1230 if cluster {
1231 client
1232 .stop_cluster_job(name, duration_millis_u64(timeout))
1233 .await
1234 } else {
1235 client.stop_job(name).await
1236 }
1237 };
1238 let result = tokio::time::timeout(timeout, request).await
1239 .map_err(|_| "peer StopJob timed out".to_owned())
1240 .and_then(|result| result.map_err(|error| error.to_string()));
1241 let reconnect = result.is_err();
1242 let _ = reply.send(result);
1243 if reconnect {
1244 return true;
1245 }
1246 }
1247 SessionCommand::ListClusterJobs { timeout, reply } => {
1248 let result = tokio::time::timeout(
1249 timeout,
1250 client.list_cluster_jobs(duration_millis_u64(timeout)),
1251 )
1252 .await
1253 .map_err(|_| "peer ListClusterJobs timed out".to_owned())
1254 .and_then(|result| result.map_err(|error| error.to_string()));
1255 let reconnect = result.is_err();
1256 let _ = reply.send(result);
1257 if reconnect {
1258 return true;
1259 }
1260 }
1261 SessionCommand::SubscribeMetrics {
1262 interval,
1263 job_names,
1264 timeout,
1265 reply,
1266 } => {
1267 let result = tokio::time::timeout(
1268 timeout,
1269 client.subscribe_local_metrics(
1270 duration_millis_u64(interval),
1271 job_names,
1272 ),
1273 )
1274 .await
1275 .map_err(|_| "peer SubscribeMetrics timed out".to_owned())
1276 .and_then(|result| result.map_err(|error| error.to_string()));
1277 let reconnect = result.is_err();
1278 let _ = reply.send(result);
1279 if reconnect {
1280 return true;
1281 }
1282 }
1283 SessionCommand::RememberClusterAssignment {
1284 instance_name,
1285 assignment,
1286 timeout,
1287 reply,
1288 } => {
1289 let result = tokio::time::timeout(
1290 timeout,
1291 client.remember_cluster_assignment(instance_name, assignment),
1292 )
1293 .await
1294 .map_err(|_| "peer RememberClusterAssignment timed out".to_owned())
1295 .and_then(|result| result.map_err(|error| error.to_string()));
1296 let reconnect = result.is_err();
1297 let _ = reply.send(result);
1298 if reconnect {
1299 return true;
1300 }
1301 }
1302 SessionCommand::AllocateShard {
1303 type_name,
1304 shard_id,
1305 timeout,
1306 reply,
1307 } => {
1308 let timeout_ms = duration_millis_u64(timeout);
1309 let result = tokio::time::timeout(
1310 timeout,
1311 client.allocate_shard(type_name, shard_id, timeout_ms),
1312 )
1313 .await
1314 .map_err(|_| "peer AllocateShard timed out".to_owned())
1315 .and_then(|result| result.map_err(|error| error.to_string()));
1316 let reconnect = result.is_err();
1317 let _ = reply.send(result);
1318 if reconnect {
1319 return true;
1320 }
1321 }
1322 SessionCommand::RememberShardAllocations {
1323 table,
1324 timeout,
1325 reply,
1326 } => {
1327 let result = tokio::time::timeout(
1328 timeout,
1329 client.remember_shard_allocations(table),
1330 )
1331 .await
1332 .map_err(|_| "peer RememberShardAllocations timed out".to_owned())
1333 .and_then(|result| result.map_err(|error| error.to_string()));
1334 let reconnect = result.is_err();
1335 let _ = reply.send(result);
1336 if reconnect {
1337 return true;
1338 }
1339 }
1340 SessionCommand::GetShardAllocations {
1341 type_name,
1342 timeout,
1343 reply,
1344 } => {
1345 let timeout_ms = duration_millis_u64(timeout);
1346 let result = tokio::time::timeout(
1347 timeout,
1348 client.get_shard_allocations(type_name, timeout_ms),
1349 )
1350 .await
1351 .map_err(|_| "peer GetShardAllocations timed out".to_owned())
1352 .and_then(|result| result.map_err(|error| error.to_string()));
1353 let reconnect = result.is_err();
1354 let _ = reply.send(result);
1355 if reconnect {
1356 return true;
1357 }
1358 }
1359 SessionCommand::ForwardShardEnvelopes {
1360 batch,
1361 timeout,
1362 reply,
1363 } => {
1364 let timeout_ms = duration_millis_u64(timeout);
1365 let result = tokio::time::timeout(
1366 timeout,
1367 client.forward_shard_envelopes(batch, timeout_ms),
1368 )
1369 .await
1370 .map_err(|_| "peer ForwardShardEnvelopes timed out".to_owned())
1371 .and_then(|result| result.map_err(|error| error.to_string()));
1372 let reconnect = result.is_err();
1373 let _ = reply.send(result);
1374 if reconnect {
1375 return true;
1376 }
1377 }
1378 SessionCommand::CompleteShardingAsk {
1379 response,
1380 timeout,
1381 reply,
1382 } => {
1383 let result =
1384 tokio::time::timeout(timeout, client.complete_sharding_ask(response))
1385 .await
1386 .map_err(|_| "peer CompleteShardingAsk timed out".to_owned())
1387 .and_then(|result| result.map_err(|error| error.to_string()));
1388 let reconnect = result.is_err();
1389 let _ = reply.send(result);
1390 if reconnect {
1391 return true;
1392 }
1393 }
1394 }
1395 }
1396 }
1397 }
1398}
1399
1400const SHARD_PIPE_MAX_BATCH_COMMANDS: usize = 1024;
1401
1402fn build_shard_pipe_frame(
1403 first: ShardPipeCommand,
1404 pipe_commands: &mut mpsc::Receiver<ShardPipeCommand>,
1405) -> ShardPipeFrame {
1406 let mut frame = ShardPipeFrame {
1407 forwards: Vec::new(),
1408 replies: Vec::new(),
1409 };
1410 push_shard_pipe_command(&mut frame, first);
1411 for _ in 1..SHARD_PIPE_MAX_BATCH_COMMANDS {
1412 match pipe_commands.try_recv() {
1413 Ok(command) => push_shard_pipe_command(&mut frame, command),
1414 Err(_) => break,
1415 }
1416 }
1417 frame
1418}
1419
1420fn push_shard_pipe_command(frame: &mut ShardPipeFrame, command: ShardPipeCommand) {
1421 match command {
1422 ShardPipeCommand::Forward { batch } => {
1423 if let Some(existing) = frame
1424 .forwards
1425 .iter_mut()
1426 .find(|existing| existing.type_name == batch.type_name)
1427 {
1428 existing.envelopes.extend(batch.envelopes);
1429 } else {
1430 frame.forwards.push(batch);
1431 }
1432 }
1433 ShardPipeCommand::Reply { response } => {
1434 frame.replies.push(response);
1435 }
1436 }
1437}
1438
1439async fn backoff_or_stop(
1440 backoff: Duration,
1441 error: String,
1442 commands: &mut mpsc::Receiver<SessionCommand>,
1443 pipe_commands: &mut mpsc::Receiver<ShardPipeCommand>,
1444 stop: &mut watch::Receiver<bool>,
1445) -> bool {
1446 let sleep = sleep(backoff);
1447 tokio::pin!(sleep);
1448 loop {
1449 tokio::select! {
1450 () = &mut sleep => return true,
1451 changed = stop.changed() => {
1452 return changed.is_ok() && !*stop.borrow();
1453 }
1454 command = pipe_commands.recv() => {
1455 if command.is_none() {
1456 return false;
1457 }
1458 }
1459 command = commands.recv() => {
1460 let Some(command) = command else {
1461 return false;
1462 };
1463 match command {
1464 SessionCommand::ListJobs { reply, .. } => {
1465 let _ = reply.send(Err(format!("node session unreachable: {error}")));
1466 }
1467 SessionCommand::SubmitClusterJob { reply, .. }
1468 | SessionCommand::StartClusterJob { reply, .. }
1469 | SessionCommand::ClusterJobStatus { reply, .. }
1470 | SessionCommand::DrainClusterJob { reply, .. }
1471 | SessionCommand::StopClusterJob { reply, .. } => {
1472 let _ = reply.send(Err(format!("node session unreachable: {error}")));
1473 }
1474 SessionCommand::ListClusterJobs { reply, .. } => {
1475 let _ = reply.send(Err(format!("node session unreachable: {error}")));
1476 }
1477 SessionCommand::SubscribeMetrics { reply, .. } => {
1478 let _ = reply.send(Err(format!("node session unreachable: {error}")));
1479 }
1480 SessionCommand::RememberClusterAssignment { reply, .. } => {
1481 let _ = reply.send(Err(format!("node session unreachable: {error}")));
1482 }
1483 SessionCommand::AllocateShard { reply, .. } => {
1484 let _ = reply.send(Err(format!("node session unreachable: {error}")));
1485 }
1486 SessionCommand::RememberShardAllocations { reply, .. } => {
1487 let _ = reply.send(Err(format!("node session unreachable: {error}")));
1488 }
1489 SessionCommand::GetShardAllocations { reply, .. } => {
1490 let _ = reply.send(Err(format!("node session unreachable: {error}")));
1491 }
1492 SessionCommand::ForwardShardEnvelopes { reply, .. } => {
1493 let _ = reply.send(Err(format!("node session unreachable: {error}")));
1494 }
1495 SessionCommand::CompleteShardingAsk { reply, .. } => {
1496 let _ = reply.send(Err(format!("node session unreachable: {error}")));
1497 }
1498 }
1499 }
1500 }
1501 }
1502}
1503
1504fn default_wire_placement() -> PlacementSpec {
1505 PlacementSpec {
1506 role_constraint: String::new(),
1507 strategy: PlacementStrategy::LeastJobs as i32,
1508 pinned_node_id: String::new(),
1509 }
1510}
1511
1512fn duration_millis_u64(duration: Duration) -> u64 {
1513 duration.as_millis().min(u128::from(u64::MAX)) as u64
1514}
1515
1516fn next_backoff(current: Duration, max: Duration) -> Duration {
1517 current.saturating_mul(2).min(max)
1518}
1519
1520#[derive(Clone)]
1521struct PlacementCoordinatorHandle {
1522 commands: mpsc::Sender<PlacementCommand>,
1523 assignments: watch::Receiver<Arc<BTreeMap<String, String>>>,
1524 tasks: Arc<Mutex<Vec<JoinHandle<()>>>>,
1525}
1526
1527enum PlacementCommand {
1528 Submit {
1529 request: SubmitClusterJob,
1530 timeout: Duration,
1531 reply: oneshot::Sender<Result<crate::dcp::proto::JobStatus, DcpError>>,
1532 },
1533 Status {
1534 name: String,
1535 timeout: Duration,
1536 reply: oneshot::Sender<Result<crate::dcp::proto::JobStatus, DcpError>>,
1537 },
1538 Drain {
1539 name: String,
1540 timeout: Duration,
1541 reply: oneshot::Sender<Result<crate::dcp::proto::JobStatus, DcpError>>,
1542 },
1543 Stop {
1544 name: String,
1545 timeout: Duration,
1546 reply: oneshot::Sender<Result<crate::dcp::proto::JobStatus, DcpError>>,
1547 },
1548 Remember {
1549 instance_name: String,
1550 assignment: Option<ClusterJobStart>,
1551 reply: oneshot::Sender<Result<(), DcpError>>,
1552 },
1553 RegisterRestarted {
1554 instance_name: String,
1555 assignment: ClusterJobStart,
1556 timeout: Duration,
1557 reply: oneshot::Sender<Result<(), DcpError>>,
1558 },
1559 Tick,
1560 Shutdown,
1561}
1562
1563struct PlacementActor {
1564 self_node: String,
1565 agent_role: String,
1566 state: Signal<ClusterState>,
1567 registry: JobRegistryHandle,
1568 sessions: NodeSessionManagerHandle,
1569 factories: DcpJobFactories,
1570 request_timeout: Duration,
1571 active_coordinator: bool,
1572 last_known_coordinator: Option<String>,
1573 assignments: BTreeMap<String, ClusterJobMetadata>,
1574 assignment_updates: watch::Sender<Arc<BTreeMap<String, String>>>,
1575}
1576
1577impl PlacementCoordinatorHandle {
1578 fn start(
1579 self_node: String,
1580 agent_role: String,
1581 state: Signal<ClusterState>,
1582 registry: JobRegistryHandle,
1583 sessions: NodeSessionManagerHandle,
1584 factories: DcpJobFactories,
1585 request_timeout: Duration,
1586 ) -> ClusterAgentResult<Self> {
1587 let (commands, receiver) = mpsc::channel(128);
1588 let (assignment_updates, assignments) = watch::channel(Arc::new(BTreeMap::new()));
1589 let tasks = Arc::new(Mutex::new(Vec::new()));
1590 let actor = PlacementActor {
1591 self_node,
1592 agent_role,
1593 state,
1594 registry,
1595 sessions,
1596 factories,
1597 request_timeout,
1598 active_coordinator: false,
1599 last_known_coordinator: None,
1600 assignments: BTreeMap::new(),
1601 assignment_updates,
1602 };
1603 let actor_task = tokio::spawn(run_placement_actor(actor, receiver));
1604 let tick_commands = commands.clone();
1605 let tick_task = tokio::spawn(async move {
1606 let mut interval = tokio::time::interval(Duration::from_millis(50));
1607 loop {
1608 interval.tick().await;
1609 if tick_commands.send(PlacementCommand::Tick).await.is_err() {
1610 break;
1611 }
1612 }
1613 });
1614 {
1615 let mut locked = tasks.lock().expect("placement tasks poisoned");
1616 locked.push(actor_task);
1617 locked.push(tick_task);
1618 }
1619 Ok(Self {
1620 commands,
1621 assignments,
1622 tasks,
1623 })
1624 }
1625
1626 async fn submit_cluster_job(
1627 &self,
1628 request: SubmitClusterJob,
1629 timeout: Duration,
1630 ) -> Result<crate::dcp::proto::JobStatus, DcpError> {
1631 self.request(timeout, |reply| PlacementCommand::Submit {
1632 request,
1633 timeout,
1634 reply,
1635 })
1636 .await
1637 }
1638
1639 async fn cluster_job_status(
1640 &self,
1641 name: String,
1642 timeout: Duration,
1643 ) -> Result<crate::dcp::proto::JobStatus, DcpError> {
1644 self.request(timeout, |reply| PlacementCommand::Status {
1645 name,
1646 timeout,
1647 reply,
1648 })
1649 .await
1650 }
1651
1652 async fn drain_cluster_job(
1653 &self,
1654 name: String,
1655 timeout: Duration,
1656 ) -> Result<crate::dcp::proto::JobStatus, DcpError> {
1657 self.request(timeout, |reply| PlacementCommand::Drain {
1658 name,
1659 timeout,
1660 reply,
1661 })
1662 .await
1663 }
1664
1665 async fn stop_cluster_job(
1666 &self,
1667 name: String,
1668 timeout: Duration,
1669 ) -> Result<crate::dcp::proto::JobStatus, DcpError> {
1670 self.request(timeout, |reply| PlacementCommand::Stop {
1671 name,
1672 timeout,
1673 reply,
1674 })
1675 .await
1676 }
1677
1678 async fn remember_cluster_assignment(
1679 &self,
1680 instance_name: String,
1681 assignment: Option<ClusterJobStart>,
1682 ) -> Result<(), DcpError> {
1683 self.request(Duration::from_millis(250), |reply| {
1684 PlacementCommand::Remember {
1685 instance_name,
1686 assignment,
1687 reply,
1688 }
1689 })
1690 .await
1691 }
1692
1693 async fn register_restarted_cluster_assignment(
1694 &self,
1695 instance_name: String,
1696 assignment: ClusterJobStart,
1697 timeout: Duration,
1698 ) -> Result<(), DcpError> {
1699 self.request(timeout, |reply| PlacementCommand::RegisterRestarted {
1700 instance_name,
1701 assignment,
1702 timeout,
1703 reply,
1704 })
1705 .await
1706 }
1707
1708 fn subscribe_assignments(&self) -> watch::Receiver<Arc<BTreeMap<String, String>>> {
1709 self.assignments.clone()
1710 }
1711
1712 async fn shutdown(&self) {
1713 let _ = self.commands.send(PlacementCommand::Shutdown).await;
1714 let tasks = {
1715 let mut locked = self.tasks.lock().expect("placement tasks poisoned");
1716 std::mem::take(&mut *locked)
1717 };
1718 for task in tasks {
1719 task.abort();
1720 }
1721 }
1722
1723 async fn request<T, F>(&self, timeout: Duration, make: F) -> Result<T, DcpError>
1724 where
1725 T: Send + 'static,
1726 F: FnOnce(oneshot::Sender<Result<T, DcpError>>) -> PlacementCommand,
1727 {
1728 let (reply, receiver) = oneshot::channel();
1729 self.commands
1730 .send(make(reply))
1731 .await
1732 .map_err(|_| DcpError::response(ResponseStatus::Failed, "placement actor stopped"))?;
1733 tokio::time::timeout(timeout.saturating_add(Duration::from_millis(250)), receiver)
1734 .await
1735 .map_err(|_| {
1736 DcpError::response(
1737 ResponseStatus::DeadlineExceeded,
1738 "placement request timed out",
1739 )
1740 })?
1741 .map_err(|_| DcpError::response(ResponseStatus::Failed, "placement actor stopped"))?
1742 }
1743}
1744
1745async fn run_placement_actor(
1746 mut actor: PlacementActor,
1747 mut receiver: mpsc::Receiver<PlacementCommand>,
1748) {
1749 while let Some(command) = receiver.recv().await {
1750 match command {
1751 PlacementCommand::Submit {
1752 request,
1753 timeout,
1754 reply,
1755 } => {
1756 let result = actor.submit(request, timeout).await;
1757 let _ = reply.send(result);
1758 }
1759 PlacementCommand::Status {
1760 name,
1761 timeout,
1762 reply,
1763 } => {
1764 let result = actor.cluster_job_status(name, timeout).await;
1765 let _ = reply.send(result);
1766 }
1767 PlacementCommand::Drain {
1768 name,
1769 timeout,
1770 reply,
1771 } => {
1772 let result = actor.drain_cluster_job(name, timeout).await;
1773 let _ = reply.send(result);
1774 }
1775 PlacementCommand::Stop {
1776 name,
1777 timeout,
1778 reply,
1779 } => {
1780 let result = actor.stop_cluster_job(name, timeout).await;
1781 let _ = reply.send(result);
1782 }
1783 PlacementCommand::Remember {
1784 instance_name,
1785 assignment,
1786 reply,
1787 } => {
1788 let result = actor.remember(instance_name, assignment).await;
1789 let _ = reply.send(result);
1790 }
1791 PlacementCommand::RegisterRestarted {
1792 instance_name,
1793 assignment,
1794 timeout,
1795 reply,
1796 } => {
1797 let result = actor
1798 .register_restarted(instance_name, assignment, timeout)
1799 .await;
1800 let _ = reply.send(result);
1801 }
1802 PlacementCommand::Tick => {
1803 let _ = actor.reconcile().await;
1804 }
1805 PlacementCommand::Shutdown => break,
1806 }
1807 }
1808}
1809
1810impl PlacementActor {
1811 async fn submit(
1812 &mut self,
1813 request: SubmitClusterJob,
1814 timeout: Duration,
1815 ) -> Result<crate::dcp::proto::JobStatus, DcpError> {
1816 self.ensure_active_coordinator(timeout).await?;
1817 validate_submit(&request)?;
1818 if self.assignments.contains_key(&request.instance_name) {
1819 return Err(DcpError::response(
1820 ResponseStatus::Conflict,
1821 format!("cluster job already exists: {}", request.instance_name),
1822 ));
1823 }
1824
1825 let placement = placement_spec_from_wire(request.placement.clone())?;
1826 let target = self.choose_submit_target(&placement)?;
1827 let history = ClusterPlacementHistory {
1828 generation: 1,
1829 from_node: None,
1830 to_node: target.clone(),
1831 reason: "submitted".to_owned(),
1832 timestamp: SystemTime::now(),
1833 };
1834 let metadata = ClusterJobMetadata {
1835 factory_name: request.factory_name.clone(),
1836 params: request.params.clone().into_iter().collect(),
1837 placement,
1838 coordinator_node: self.self_node.clone(),
1839 assigned_node: target.clone(),
1840 placement_generation: 1,
1841 history: vec![history],
1842 };
1843 let status = self
1844 .start_on_node(
1845 &target,
1846 request.factory_name,
1847 request.instance_name.clone(),
1848 request.params,
1849 metadata.clone(),
1850 timeout,
1851 )
1852 .await?;
1853 self.assignments
1854 .insert(request.instance_name.clone(), metadata.clone());
1855 self.publish_assignments();
1856 self.publish_assignment(request.instance_name, metadata, timeout)
1857 .await?;
1858 Ok(status)
1859 }
1860
1861 async fn cluster_job_status(
1862 &mut self,
1863 name: String,
1864 timeout: Duration,
1865 ) -> Result<crate::dcp::proto::JobStatus, DcpError> {
1866 self.ensure_active_coordinator(timeout).await?;
1867 let metadata = self.assignment_or_rebuild(&name, timeout).await?;
1868 let mut status = self
1869 .status_on_node(&metadata.assigned_node, name, timeout)
1870 .await?;
1871 status.coordinator_node_id.clone_from(&self.self_node);
1872 Ok(status)
1873 }
1874
1875 async fn drain_cluster_job(
1876 &mut self,
1877 name: String,
1878 timeout: Duration,
1879 ) -> Result<crate::dcp::proto::JobStatus, DcpError> {
1880 self.ensure_active_coordinator(timeout).await?;
1881 let metadata = self.assignment_or_rebuild(&name, timeout).await?;
1882 let status = self
1883 .drain_on_node(&metadata.assigned_node, name.clone(), timeout)
1884 .await?;
1885 self.assignments.remove(&name);
1886 self.publish_assignments();
1887 self.replicate_assignment_mutation(name, None, timeout)
1888 .await;
1889 Ok(status)
1890 }
1891
1892 async fn stop_cluster_job(
1893 &mut self,
1894 name: String,
1895 timeout: Duration,
1896 ) -> Result<crate::dcp::proto::JobStatus, DcpError> {
1897 self.ensure_active_coordinator(timeout).await?;
1898 let metadata = self.assignment_or_rebuild(&name, timeout).await?;
1899 let status = self
1900 .stop_on_node(&metadata.assigned_node, name.clone(), timeout)
1901 .await?;
1902 self.assignments.remove(&name);
1903 self.publish_assignments();
1904 self.replicate_assignment_mutation(name, None, timeout)
1905 .await;
1906 Ok(status)
1907 }
1908
1909 async fn remember(
1910 &mut self,
1911 instance_name: String,
1912 assignment: Option<ClusterJobStart>,
1913 ) -> Result<(), DcpError> {
1914 let Some(assignment) = assignment else {
1915 if self.assignments.remove(&instance_name).is_some() {
1916 self.publish_assignments();
1917 }
1918 return Ok(());
1919 };
1920 let metadata = cluster_metadata_from_wire(assignment)?;
1921 if !remember_assignment(
1922 &mut self.assignments,
1923 instance_name.clone(),
1924 metadata.clone(),
1925 ) {
1926 return Ok(());
1927 }
1928 self.publish_assignments();
1929 self.update_local_assignment(&instance_name, &metadata)
1930 .await?;
1931 Ok(())
1932 }
1933
1934 async fn register_restarted(
1935 &mut self,
1936 instance_name: String,
1937 assignment: ClusterJobStart,
1938 timeout: Duration,
1939 ) -> Result<(), DcpError> {
1940 let mut metadata = cluster_metadata_from_wire(assignment)?;
1941 if metadata.assigned_node != self.self_node {
1942 return Err(DcpError::response(
1943 ResponseStatus::Conflict,
1944 "only the owning node can re-register restarted cluster intent",
1945 ));
1946 }
1947 let snapshot = self.state.get();
1948 metadata.coordinator_node = snapshot
1949 .placement_coordinator()
1950 .map(|member| member.node_id.clone())
1951 .ok_or_else(|| {
1952 DcpError::response(
1953 ResponseStatus::Failed,
1954 "no placement coordinator is available",
1955 )
1956 })?;
1957 drop(snapshot);
1958 self.assignments
1959 .insert(instance_name.clone(), metadata.clone());
1960 self.publish_assignments();
1961 self.publish_assignment(instance_name, metadata, timeout)
1962 .await
1963 }
1964
1965 async fn reconcile(&mut self) -> Result<(), DcpError> {
1966 let snapshot = self.state.get();
1967 let coordinator_id = snapshot
1968 .placement_coordinator()
1969 .map(|member| member.node_id.clone());
1970 let is_coordinator = snapshot.is_placement_coordinator(&self.self_node);
1971 drop(snapshot);
1972
1973 if coordinator_id != self.last_known_coordinator {
1974 if let Some(coordinator_id) = &coordinator_id {
1975 eprintln!(
1976 "datum-agent INFO coordinator_changed node_id={} new_coordinator={coordinator_id}",
1977 self.self_node
1978 );
1979 }
1980 self.last_known_coordinator = coordinator_id;
1981 }
1982
1983 if !is_coordinator {
1984 if self.active_coordinator {
1985 eprintln!(
1986 "datum-agent INFO placement_coordinator_inactive node_id={}",
1987 self.self_node
1988 );
1989 }
1990 self.active_coordinator = false;
1991 return Ok(());
1992 }
1993 self.ensure_active_coordinator(self.request_timeout).await?;
1994 self.replace_down_members(self.request_timeout).await
1995 }
1996
1997 async fn ensure_active_coordinator(&mut self, timeout: Duration) -> Result<(), DcpError> {
1998 let snapshot = self.state.get();
1999 if !snapshot.is_placement_coordinator(&self.self_node) {
2000 return Err(DcpError::response(
2001 ResponseStatus::Failed,
2002 "this node is not the placement coordinator",
2003 ));
2004 }
2005 drop(snapshot);
2006 if !self.active_coordinator {
2007 self.rebuild_from_registries(timeout).await?;
2008 self.active_coordinator = true;
2009 eprintln!(
2010 "datum-agent INFO placement_coordinator_active node_id={}",
2011 self.self_node
2012 );
2013 }
2014 Ok(())
2015 }
2016
2017 async fn assignment_or_rebuild(
2018 &mut self,
2019 name: &str,
2020 timeout: Duration,
2021 ) -> Result<ClusterJobMetadata, DcpError> {
2022 if let Some(metadata) = self.assignments.get(name).cloned() {
2023 return Ok(metadata);
2024 }
2025 self.rebuild_from_registries(timeout).await?;
2026 self.assignments.get(name).cloned().ok_or_else(|| {
2027 DcpError::response(
2028 ResponseStatus::NotFound,
2029 format!("cluster job not found: {name}"),
2030 )
2031 })
2032 }
2033
2034 async fn rebuild_from_registries(&mut self, timeout: Duration) -> Result<(), DcpError> {
2035 let statuses = self.collect_job_statuses(timeout).await?;
2041 let mut running = BTreeSet::new();
2042 let mut inactive = BTreeSet::new();
2043 let mut inactive_metadata = BTreeMap::new();
2044 for status in statuses {
2045 if !status.cluster_job {
2046 continue;
2047 }
2048 if status.desired_state == "Running"
2049 && let Some(mut metadata) = metadata_from_status(&status)?
2050 {
2051 metadata.coordinator_node.clone_from(&self.self_node);
2052 running.insert(status.name.clone());
2053 inactive.remove(&status.name);
2054 inactive_metadata.remove(&status.name);
2055 let replace = self
2056 .assignments
2057 .get(&status.name)
2058 .map(|existing| {
2059 metadata.placement_generation > existing.placement_generation
2060 || (metadata.placement_generation == existing.placement_generation
2061 && metadata.assigned_node < existing.assigned_node)
2062 })
2063 .unwrap_or(true);
2064 if replace {
2065 self.assignments.insert(status.name.clone(), metadata);
2066 self.publish_assignments();
2067 }
2068 } else if !running.contains(&status.name) {
2069 if let Some(mut metadata) = metadata_from_status(&status)? {
2070 metadata.coordinator_node.clone_from(&self.self_node);
2071 inactive_metadata.insert(status.name.clone(), metadata);
2072 }
2073 inactive.insert(status.name);
2074 }
2075 }
2076 for name in &inactive {
2077 if self.assignments.remove(name).is_some() {
2078 self.publish_assignments();
2079 }
2080 }
2081 for metadata in self.assignments.values_mut() {
2082 metadata.coordinator_node.clone_from(&self.self_node);
2083 }
2084 self.publish_assignments();
2085
2086 let assignments = self
2087 .assignments
2088 .iter()
2089 .map(|(name, metadata)| (name.clone(), metadata.clone()))
2090 .collect::<Vec<_>>();
2091 for (name, metadata) in assignments {
2092 self.publish_assignment(name, metadata, timeout).await?;
2093 }
2094 for name in inactive {
2095 if let Some(metadata) = inactive_metadata.remove(&name) {
2096 self.publish_assignment(name.clone(), metadata, timeout)
2097 .await?;
2098 }
2099 self.replicate_assignment_mutation(name, None, timeout)
2100 .await;
2101 }
2102 Ok(())
2103 }
2104
2105 async fn replace_down_members(&mut self, timeout: Duration) -> Result<(), DcpError> {
2106 let snapshot = self.state.get();
2107 let down_nodes = snapshot
2108 .members
2109 .values()
2110 .filter(|member| member.state == MemberState::Down)
2111 .map(|member| member.node_id.clone())
2112 .collect::<BTreeSet<_>>();
2113 drop(snapshot);
2114 if down_nodes.is_empty() {
2115 return Ok(());
2116 }
2117
2118 let to_replace = self
2119 .assignments
2120 .iter()
2121 .filter(|(_name, metadata)| down_nodes.contains(&metadata.assigned_node))
2122 .map(|(name, metadata)| (name.clone(), metadata.clone()))
2123 .collect::<Vec<_>>();
2124
2125 for (name, mut metadata) in to_replace {
2126 let old_node = metadata.assigned_node.clone();
2127 let target = self.choose_replacement_target(&metadata.placement, &old_node)?;
2128 metadata.placement_generation = metadata.placement_generation.saturating_add(1);
2129 metadata.assigned_node = target.clone();
2130 metadata.coordinator_node = self.self_node.clone();
2131 let reason = format!("node_down:{old_node}");
2132 metadata.history.push(ClusterPlacementHistory {
2133 generation: metadata.placement_generation,
2134 from_node: Some(old_node.clone()),
2135 to_node: target.clone(),
2136 reason: reason.clone(),
2137 timestamp: SystemTime::now(),
2138 });
2139 eprintln!(
2140 "datum-agent INFO job_replaced job={name} from_node={old_node} to_node={target} reason={reason} generation={}",
2141 metadata.placement_generation
2142 );
2143 let params = metadata.params.clone().into_iter().collect();
2144 let factory_name = metadata.factory_name.clone();
2145 self.start_on_node(
2146 &target,
2147 factory_name,
2148 name.clone(),
2149 params,
2150 metadata.clone(),
2151 timeout,
2152 )
2153 .await?;
2154 self.assignments.insert(name.clone(), metadata.clone());
2155 self.publish_assignments();
2156 self.publish_assignment(name, metadata, timeout).await?;
2157 }
2158 Ok(())
2159 }
2160
2161 fn publish_assignments(&self) {
2162 let assignments = self
2163 .assignments
2164 .iter()
2165 .map(|(name, metadata)| (name.clone(), metadata.assigned_node.clone()))
2166 .collect();
2167 self.assignment_updates.send_replace(Arc::new(assignments));
2168 }
2169
2170 fn choose_submit_target(&self, placement: &RegistryPlacementSpec) -> Result<String, DcpError> {
2171 let snapshot = self.state.get();
2172 let candidates = eligible_members(
2173 &snapshot,
2174 &self.agent_role,
2175 placement.role_constraint.as_deref(),
2176 );
2177 if candidates.is_empty() {
2178 return Err(no_eligible_node_error(placement));
2179 }
2180 match &placement.strategy {
2181 RegistryPlacementStrategy::LeastJobs => self.least_loaded(candidates),
2182 RegistryPlacementStrategy::Pinned { node_id } => candidates
2183 .into_iter()
2184 .find(|member| member.node_id == *node_id)
2185 .map(|member| member.node_id.clone())
2186 .ok_or_else(|| {
2187 DcpError::response(
2188 ResponseStatus::NotFound,
2189 format!("pinned node is not eligible for placement: {node_id}"),
2190 )
2191 }),
2192 }
2193 }
2194
2195 fn choose_replacement_target(
2196 &self,
2197 placement: &RegistryPlacementSpec,
2198 old_node: &str,
2199 ) -> Result<String, DcpError> {
2200 let snapshot = self.state.get();
2201 let candidates = eligible_members(
2202 &snapshot,
2203 &self.agent_role,
2204 placement.role_constraint.as_deref(),
2205 )
2206 .into_iter()
2207 .filter(|member| member.node_id != old_node)
2208 .collect::<Vec<_>>();
2209 if candidates.is_empty() {
2210 return Err(no_eligible_node_error(placement));
2211 }
2212 self.least_loaded(candidates)
2213 }
2214
2215 fn least_loaded(&self, candidates: Vec<&Member>) -> Result<String, DcpError> {
2216 candidates
2217 .into_iter()
2218 .min_by(|left, right| {
2219 let left_count = self.jobs_on_node(&left.node_id);
2220 let right_count = self.jobs_on_node(&right.node_id);
2221 left_count
2222 .cmp(&right_count)
2223 .then_with(|| left.node_id.cmp(&right.node_id))
2224 })
2225 .map(|member| member.node_id.clone())
2226 .ok_or_else(|| DcpError::response(ResponseStatus::NotFound, "no eligible node"))
2227 }
2228
2229 fn jobs_on_node(&self, node_id: &str) -> usize {
2230 self.assignments
2231 .values()
2232 .filter(|metadata| metadata.assigned_node == node_id)
2233 .count()
2234 }
2235
2236 async fn start_on_node(
2237 &self,
2238 node_id: &str,
2239 factory_name: String,
2240 instance_name: String,
2241 params: HashMap<String, String>,
2242 metadata: ClusterJobMetadata,
2243 timeout: Duration,
2244 ) -> Result<crate::dcp::proto::JobStatus, DcpError> {
2245 if node_id == self.self_node {
2246 let mut spec = self
2247 .factories
2248 .build(&factory_name, instance_name.clone(), params)?;
2249 spec = spec.with_cluster_metadata(metadata);
2250 let registry = self.registry.clone();
2251 let status = tokio::task::spawn_blocking(move || {
2252 registry.submit(spec)?;
2253 registry.start(instance_name)
2254 })
2255 .await??;
2256 return Ok(wire_job_status(&status));
2257 }
2258
2259 self.sessions
2260 .start_cluster_job(
2261 node_id,
2262 factory_name,
2263 instance_name,
2264 params,
2265 wire_cluster_job_start(&metadata),
2266 timeout,
2267 )
2268 .await
2269 .map_err(session_error)
2270 }
2271
2272 async fn status_on_node(
2273 &self,
2274 node_id: &str,
2275 name: String,
2276 timeout: Duration,
2277 ) -> Result<crate::dcp::proto::JobStatus, DcpError> {
2278 if node_id == self.self_node {
2279 let registry = self.registry.clone();
2280 let status = tokio::task::spawn_blocking(move || registry.status(name)).await??;
2281 return Ok(wire_job_status(&status));
2282 }
2283 self.sessions
2284 .cluster_job_status(node_id, name, timeout)
2285 .await
2286 .map_err(session_error)
2287 }
2288
2289 async fn drain_on_node(
2290 &self,
2291 node_id: &str,
2292 name: String,
2293 timeout: Duration,
2294 ) -> Result<crate::dcp::proto::JobStatus, DcpError> {
2295 if node_id == self.self_node {
2296 let registry = self.registry.clone();
2297 let status = tokio::task::spawn_blocking(move || registry.drain(name)).await??;
2298 return Ok(wire_job_status(&status));
2299 }
2300 self.sessions
2301 .drain_cluster_job(node_id, name, timeout)
2302 .await
2303 .map_err(session_error)
2304 }
2305
2306 async fn stop_on_node(
2307 &self,
2308 node_id: &str,
2309 name: String,
2310 timeout: Duration,
2311 ) -> Result<crate::dcp::proto::JobStatus, DcpError> {
2312 if node_id == self.self_node {
2313 let registry = self.registry.clone();
2314 let status = tokio::task::spawn_blocking(move || registry.stop(name)).await??;
2315 return Ok(wire_job_status(&status));
2316 }
2317 self.sessions
2318 .stop_cluster_job(node_id, name, timeout)
2319 .await
2320 .map_err(session_error)
2321 }
2322
2323 async fn collect_job_statuses(
2324 &self,
2325 timeout: Duration,
2326 ) -> Result<Vec<crate::dcp::proto::JobStatus>, DcpError> {
2327 let snapshot = self.state.get();
2328 let mut statuses = Vec::new();
2329 let registry = self.registry.clone();
2330 let local_jobs = tokio::task::spawn_blocking(move || registry.list()).await??;
2331 statuses.extend(local_jobs.iter().map(wire_job_status));
2332
2333 let mut pending = Vec::new();
2334 for member in snapshot.members.values() {
2335 if member.node_id == self.self_node
2336 || !member.has_role(&self.agent_role)
2337 || member.state != MemberState::Up
2338 || member.unreachable
2339 {
2340 continue;
2341 }
2342 let node_id = member.node_id.clone();
2343 let sessions = self.sessions.clone();
2344 pending.push(tokio::spawn(async move {
2345 sessions.list_jobs(&node_id, timeout).await
2346 }));
2347 }
2348 drop(snapshot);
2349
2350 let deadline = Instant::now() + timeout;
2355 for task in pending {
2356 let remaining = deadline.saturating_duration_since(Instant::now());
2357 if remaining.is_zero() {
2358 return Err(DcpError::response(
2359 ResponseStatus::DeadlineExceeded,
2360 "registry reconciliation timed out",
2361 ));
2362 }
2363 match tokio::time::timeout(remaining, task).await {
2364 Ok(Ok(Ok(mut peer_statuses))) => statuses.append(&mut peer_statuses),
2365 Ok(Ok(Err(message))) => return Err(session_error(message)),
2366 Ok(Err(error)) => {
2367 return Err(DcpError::response(
2368 ResponseStatus::Failed,
2369 format!("registry reconciliation task failed: {error}"),
2370 ));
2371 }
2372 Err(_) => {
2373 return Err(DcpError::response(
2374 ResponseStatus::DeadlineExceeded,
2375 "registry reconciliation timed out",
2376 ));
2377 }
2378 }
2379 }
2380 Ok(statuses)
2381 }
2382
2383 async fn publish_assignment(
2384 &self,
2385 instance_name: String,
2386 metadata: ClusterJobMetadata,
2387 timeout: Duration,
2388 ) -> Result<(), DcpError> {
2389 self.update_local_assignment(&instance_name, &metadata)
2390 .await?;
2391 self.replicate_assignment_mutation(
2392 instance_name,
2393 Some(wire_cluster_job_start(&metadata)),
2394 timeout,
2395 )
2396 .await;
2397 Ok(())
2398 }
2399
2400 async fn update_local_assignment(
2401 &self,
2402 instance_name: &str,
2403 metadata: &ClusterJobMetadata,
2404 ) -> Result<(), DcpError> {
2405 if metadata.assigned_node != self.self_node {
2406 return Ok(());
2407 }
2408 let registry = self.registry.clone();
2409 let instance_name = instance_name.to_owned();
2410 let metadata = metadata.clone();
2411 tokio::task::spawn_blocking(move || {
2412 registry.update_cluster_metadata(instance_name, metadata)
2413 })
2414 .await??;
2415 Ok(())
2416 }
2417
2418 async fn replicate_assignment_mutation(
2419 &self,
2420 instance_name: String,
2421 assignment: Option<ClusterJobStart>,
2422 timeout: Duration,
2423 ) {
2424 let snapshot = self.state.get();
2425 let peers = snapshot
2426 .members
2427 .values()
2428 .filter(|member| {
2429 member.node_id != self.self_node
2430 && member.has_role(&self.agent_role)
2431 && member.state == MemberState::Up
2432 && !member.unreachable
2433 })
2434 .map(|member| member.node_id.clone())
2435 .collect::<Vec<_>>();
2436 drop(snapshot);
2437 let timeout = timeout.min(Duration::from_millis(250));
2438 for node_id in peers {
2439 let _ = self
2440 .sessions
2441 .remember_cluster_assignment(
2442 &node_id,
2443 instance_name.clone(),
2444 assignment.clone(),
2445 timeout,
2446 )
2447 .await;
2448 }
2449 }
2450}
2451
2452fn remember_assignment(
2453 assignments: &mut BTreeMap<String, ClusterJobMetadata>,
2454 instance_name: String,
2455 metadata: ClusterJobMetadata,
2456) -> bool {
2457 let accept = assignments
2458 .get(&instance_name)
2459 .is_none_or(|existing| metadata.placement_generation >= existing.placement_generation);
2460 if accept {
2461 assignments.insert(instance_name, metadata);
2462 }
2463 accept
2464}
2465
2466fn validate_submit(request: &SubmitClusterJob) -> Result<(), DcpError> {
2467 if request.factory_name.trim().is_empty() || request.instance_name.trim().is_empty() {
2468 return Err(DcpError::response(
2469 ResponseStatus::BadRequest,
2470 "SubmitClusterJob requires factory_name and instance_name",
2471 ));
2472 }
2473 Ok(())
2474}
2475
2476fn eligible_members<'a>(
2477 snapshot: &'a ClusterState,
2478 agent_role: &str,
2479 role_constraint: Option<&str>,
2480) -> Vec<&'a Member> {
2481 snapshot
2482 .members
2483 .values()
2484 .filter(|member| member.state == MemberState::Up && !member.unreachable)
2485 .filter(|member| member.has_role(agent_role))
2486 .filter(|member| role_constraint.is_none_or(|role| member.has_role(role)))
2487 .collect()
2488}
2489
2490fn no_eligible_node_error(placement: &RegistryPlacementSpec) -> DcpError {
2491 let role = placement
2492 .role_constraint
2493 .as_deref()
2494 .map(|role| format!(" with role '{role}'"))
2495 .unwrap_or_default();
2496 DcpError::response(
2497 ResponseStatus::NotFound,
2498 format!("no eligible Up placement nodes{role}"),
2499 )
2500}
2501
2502fn metadata_from_status(
2503 status: &crate::dcp::proto::JobStatus,
2504) -> Result<Option<ClusterJobMetadata>, DcpError> {
2505 if !status.cluster_job {
2506 return Ok(None);
2507 }
2508 let placement = placement_spec_from_wire(status.placement.clone())?;
2509 Ok(Some(ClusterJobMetadata {
2510 factory_name: status.factory_name.clone(),
2511 params: status.params.clone().into_iter().collect(),
2512 placement,
2513 coordinator_node: status.coordinator_node_id.clone(),
2514 assigned_node: status.placement_node_id.clone(),
2515 placement_generation: status.placement_generation,
2516 history: status
2517 .placement_history
2518 .iter()
2519 .map(|history| ClusterPlacementHistory {
2520 generation: history.generation,
2521 from_node: if history.from_node_id.is_empty() {
2522 None
2523 } else {
2524 Some(history.from_node_id.clone())
2525 },
2526 to_node: history.to_node_id.clone(),
2527 reason: history.reason.clone(),
2528 timestamp: std::time::UNIX_EPOCH + Duration::from_millis(history.timestamp_ms),
2529 })
2530 .collect(),
2531 }))
2532}
2533
2534fn session_error(message: String) -> DcpError {
2535 let status = if message.contains("DCP response NotFound") {
2536 ResponseStatus::NotFound
2537 } else if message.contains("DCP response Conflict") {
2538 ResponseStatus::Conflict
2539 } else if message.contains("DCP response BadRequest") {
2540 ResponseStatus::BadRequest
2541 } else if message.contains("DCP response DeadlineExceeded") {
2542 ResponseStatus::DeadlineExceeded
2543 } else {
2544 ResponseStatus::Failed
2545 };
2546 DcpError::response(status, message)
2547}
2548
2549struct ClusterView {
2550 registry: JobRegistryHandle,
2551 state: Signal<ClusterState>,
2552 sessions: NodeSessionManagerHandle,
2553 placement: PlacementCoordinatorHandle,
2554 self_node: String,
2555 agent_role: String,
2556}
2557
2558impl ClusterViewProvider for ClusterView {
2559 fn subscribe_cluster_metrics(
2560 &self,
2561 interval: Duration,
2562 ) -> Option<mpsc::Receiver<MetricSample>> {
2563 Some(spawn_cluster_metrics_proxy(
2564 self.self_node.clone(),
2565 self.sessions.clone(),
2566 self.placement.subscribe_assignments(),
2567 interval,
2568 ))
2569 }
2570
2571 fn submit_cluster_job(
2572 &self,
2573 request: SubmitClusterJob,
2574 timeout: Duration,
2575 ) -> crate::dcp::server::ClusterViewFuture<'_, crate::dcp::proto::JobStatus> {
2576 Box::pin(async move { self.submit_cluster_job_inner(request, timeout).await })
2577 }
2578
2579 fn list_cluster_jobs(
2580 &self,
2581 timeout: Duration,
2582 ) -> crate::dcp::server::ClusterViewFuture<'_, ClusterJobList> {
2583 Box::pin(async move { self.list_cluster_jobs_inner(timeout).await })
2584 }
2585
2586 fn cluster_node_info(
2587 &self,
2588 timeout: Duration,
2589 ) -> crate::dcp::server::ClusterViewFuture<'_, ClusterNodeList> {
2590 Box::pin(async move { self.cluster_node_info_inner(timeout).await })
2591 }
2592
2593 fn cluster_job_status(
2594 &self,
2595 name: String,
2596 timeout: Duration,
2597 ) -> crate::dcp::server::ClusterViewFuture<'_, crate::dcp::proto::JobStatus> {
2598 Box::pin(async move { self.cluster_job_status_inner(name, timeout).await })
2599 }
2600
2601 fn drain_cluster_job(
2602 &self,
2603 name: String,
2604 timeout: Duration,
2605 ) -> crate::dcp::server::ClusterViewFuture<'_, crate::dcp::proto::JobStatus> {
2606 Box::pin(async move { self.drain_cluster_job_inner(name, timeout).await })
2607 }
2608
2609 fn stop_cluster_job(
2610 &self,
2611 name: String,
2612 timeout: Duration,
2613 ) -> crate::dcp::server::ClusterViewFuture<'_, crate::dcp::proto::JobStatus> {
2614 Box::pin(async move { self.stop_cluster_job_inner(name, timeout).await })
2615 }
2616
2617 fn remember_cluster_assignment(
2618 &self,
2619 request: RememberClusterAssignment,
2620 ) -> crate::dcp::server::ClusterViewFuture<'_, ()> {
2621 Box::pin(async move { self.remember_cluster_assignment_inner(request).await })
2622 }
2623
2624 fn register_restarted_cluster_assignment(
2625 &self,
2626 instance_name: String,
2627 assignment: ClusterJobStart,
2628 timeout: Duration,
2629 ) -> crate::dcp::server::ClusterViewFuture<'_, ()> {
2630 Box::pin(async move {
2631 self.placement
2632 .register_restarted_cluster_assignment(instance_name, assignment, timeout)
2633 .await
2634 })
2635 }
2636}
2637
2638struct RemoteMetricRoute {
2639 job_names: BTreeSet<String>,
2640 cancel: oneshot::Sender<()>,
2641 task: JoinHandle<()>,
2642}
2643
2644struct RemoteMetricUpdate {
2645 node_id: String,
2646 sample: MetricSample,
2647}
2648
2649struct RemoteMetricSubscriptionAttempt {
2650 node_id: String,
2651 job_names: BTreeSet<String>,
2652 result: Result<MetricSubscription, String>,
2653}
2654
2655#[derive(Default)]
2656struct RemoteMetricProxyState {
2657 routes: BTreeMap<String, RemoteMetricRoute>,
2658 latest: BTreeMap<String, MetricSample>,
2659 pending: BTreeMap<String, BTreeSet<String>>,
2660 wanted: BTreeMap<String, BTreeSet<String>>,
2661}
2662
2663fn spawn_cluster_metrics_proxy(
2664 self_node: String,
2665 sessions: NodeSessionManagerHandle,
2666 assignments: watch::Receiver<Arc<BTreeMap<String, String>>>,
2667 interval: Duration,
2668) -> mpsc::Receiver<MetricSample> {
2669 let (output, receiver) = mpsc::channel(8);
2670 tokio::spawn(run_cluster_metrics_proxy(
2671 self_node,
2672 sessions,
2673 assignments,
2674 interval,
2675 output,
2676 ));
2677 receiver
2678}
2679
2680async fn run_cluster_metrics_proxy(
2681 self_node: String,
2682 sessions: NodeSessionManagerHandle,
2683 mut assignments: watch::Receiver<Arc<BTreeMap<String, String>>>,
2684 interval: Duration,
2685 output: mpsc::Sender<MetricSample>,
2686) {
2687 let (updates, mut update_receiver) = mpsc::channel(32);
2688 let (attempts, mut attempt_receiver) = mpsc::channel(32);
2689 let mut proxy = RemoteMetricProxyState::default();
2690 let retry_interval = interval
2691 .max(Duration::from_millis(50))
2692 .min(Duration::from_secs(1));
2693 let mut retry = tokio::time::interval(retry_interval);
2694 let mut emit = tokio::time::interval(interval);
2695
2696 let initial_assignments = assignments.borrow_and_update().clone();
2697 reconcile_metric_routes(
2698 &self_node,
2699 &sessions,
2700 &initial_assignments,
2701 interval,
2702 &attempts,
2703 &mut proxy,
2704 )
2705 .await;
2706
2707 loop {
2708 tokio::select! {
2709 _ = output.closed() => break,
2710 changed = assignments.changed() => {
2711 if changed.is_err() {
2712 break;
2713 }
2714 let current_assignments = assignments.borrow_and_update().clone();
2715 reconcile_metric_routes(
2716 &self_node,
2717 &sessions,
2718 ¤t_assignments,
2719 interval,
2720 &attempts,
2721 &mut proxy,
2722 ).await;
2723 }
2724 _ = retry.tick() => {
2725 let current_assignments = assignments.borrow().clone();
2726 reconcile_metric_routes(
2727 &self_node,
2728 &sessions,
2729 ¤t_assignments,
2730 interval,
2731 &attempts,
2732 &mut proxy,
2733 ).await;
2734 }
2735 attempt = attempt_receiver.recv() => {
2736 if let Some(attempt) = attempt {
2737 install_metric_route(
2738 attempt,
2739 &mut proxy,
2740 &updates,
2741 ).await;
2742 }
2743 }
2744 update = update_receiver.recv() => {
2745 if let Some(update) = update
2746 && proxy.routes.contains_key(&update.node_id)
2747 {
2748 proxy.latest.insert(update.node_id, update.sample);
2749 }
2750 }
2751 _ = emit.tick() => {
2752 let streams = proxy.latest
2753 .values()
2754 .flat_map(|sample| sample.streams.iter().cloned())
2755 .collect();
2756 let timestamp_ms = proxy.latest
2757 .values()
2758 .map(|sample| sample.timestamp_ms)
2759 .max()
2760 .unwrap_or(0);
2761 match output.try_send(MetricSample { timestamp_ms, streams }) {
2762 Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {}
2763 Err(mpsc::error::TrySendError::Closed(_)) => break,
2764 }
2765 }
2766 }
2767 }
2768
2769 drop(attempt_receiver);
2770 drop(attempts);
2771 cancel_metric_routes(proxy.routes).await;
2772}
2773
2774async fn reconcile_metric_routes(
2775 self_node: &str,
2776 sessions: &NodeSessionManagerHandle,
2777 assignments: &BTreeMap<String, String>,
2778 interval: Duration,
2779 attempts: &mpsc::Sender<RemoteMetricSubscriptionAttempt>,
2780 proxy: &mut RemoteMetricProxyState,
2781) {
2782 let mut next_wanted = BTreeMap::<String, BTreeSet<String>>::new();
2783 for (job_name, node_id) in assignments {
2784 if node_id != self_node {
2785 next_wanted
2786 .entry(node_id.clone())
2787 .or_default()
2788 .insert(job_name.clone());
2789 }
2790 }
2791 proxy.wanted = next_wanted;
2792
2793 let stale = proxy
2794 .routes
2795 .iter()
2796 .filter(|(node_id, route)| {
2797 proxy.wanted.get(*node_id) != Some(&route.job_names) || route.task.is_finished()
2798 })
2799 .map(|(node_id, _)| node_id.clone())
2800 .collect::<Vec<_>>();
2801 for node_id in stale {
2802 proxy.latest.remove(&node_id);
2803 if let Some(route) = proxy.routes.remove(&node_id) {
2804 cancel_metric_route(route).await;
2805 }
2806 }
2807
2808 for (node_id, job_names) in &proxy.wanted {
2809 if proxy.routes.contains_key(node_id) || proxy.pending.get(node_id) == Some(job_names) {
2810 continue;
2811 }
2812 proxy.pending.insert(node_id.clone(), job_names.clone());
2813 let timeout = sessions.inner.config.request_timeout;
2814 let sessions = sessions.clone();
2815 let attempts = attempts.clone();
2816 let node_id = node_id.clone();
2817 let job_names = job_names.clone();
2818 let _task = tokio::spawn(async move {
2819 let result = sessions
2820 .subscribe_metrics(
2821 &node_id,
2822 interval,
2823 job_names.iter().cloned().collect(),
2824 timeout,
2825 )
2826 .await;
2827 let attempt = RemoteMetricSubscriptionAttempt {
2828 node_id,
2829 job_names,
2830 result,
2831 };
2832 if let Err(error) = attempts.send(attempt).await
2833 && let Ok(subscription) = error.0.result
2834 {
2835 let _ = subscription.cancel().await;
2836 }
2837 });
2838 }
2839}
2840
2841async fn install_metric_route(
2842 attempt: RemoteMetricSubscriptionAttempt,
2843 proxy: &mut RemoteMetricProxyState,
2844 updates: &mpsc::Sender<RemoteMetricUpdate>,
2845) {
2846 let RemoteMetricSubscriptionAttempt {
2847 node_id,
2848 job_names,
2849 result,
2850 } = attempt;
2851 if proxy.pending.get(&node_id) == Some(&job_names) {
2852 proxy.pending.remove(&node_id);
2853 }
2854 let is_current =
2855 proxy.wanted.get(&node_id) == Some(&job_names) && !proxy.routes.contains_key(&node_id);
2856 let Ok(subscription) = result else {
2857 return;
2858 };
2859 if !is_current {
2860 let _ = subscription.cancel().await;
2861 return;
2862 }
2863
2864 let (cancel, cancel_receiver) = oneshot::channel();
2865 let task = tokio::spawn(pump_remote_metrics(
2866 node_id.clone(),
2867 subscription,
2868 job_names.clone(),
2869 updates.clone(),
2870 cancel_receiver,
2871 ));
2872 proxy.routes.insert(
2873 node_id,
2874 RemoteMetricRoute {
2875 job_names,
2876 cancel,
2877 task,
2878 },
2879 );
2880}
2881
2882async fn pump_remote_metrics(
2883 node_id: String,
2884 mut subscription: MetricSubscription,
2885 job_names: BTreeSet<String>,
2886 updates: mpsc::Sender<RemoteMetricUpdate>,
2887 mut cancel: oneshot::Receiver<()>,
2888) {
2889 loop {
2890 tokio::select! {
2891 _ = &mut cancel => {
2892 let _ = subscription.cancel().await;
2893 break;
2894 }
2895 sample = subscription.recv() => {
2896 let Some(mut sample) = sample else {
2897 break;
2898 };
2899 sample.streams.retain(|metric| {
2900 job_names.iter().any(|job_name| {
2901 metric.name == *job_name
2902 || metric
2903 .name
2904 .strip_prefix(job_name)
2905 .is_some_and(|suffix| suffix.starts_with(':'))
2906 })
2907 });
2908 match updates.try_send(RemoteMetricUpdate {
2909 node_id: node_id.clone(),
2910 sample,
2911 }) {
2912 Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {}
2913 Err(mpsc::error::TrySendError::Closed(_)) => {
2914 let _ = subscription.cancel().await;
2915 break;
2916 }
2917 }
2918 }
2919 }
2920 }
2921}
2922
2923async fn cancel_metric_route(route: RemoteMetricRoute) {
2924 let _ = route.cancel.send(());
2925 let _ = route.task.await;
2926}
2927
2928async fn cancel_metric_routes(routes: BTreeMap<String, RemoteMetricRoute>) {
2929 for (_, route) in routes {
2930 cancel_metric_route(route).await;
2931 }
2932}
2933
2934impl ClusterView {
2935 async fn submit_cluster_job_inner(
2936 &self,
2937 request: SubmitClusterJob,
2938 timeout: Duration,
2939 ) -> Result<crate::dcp::proto::JobStatus, DcpError> {
2940 if self.is_local_coordinator() {
2941 return self.placement.submit_cluster_job(request, timeout).await;
2942 }
2943 let coordinator = self.coordinator_node_id()?;
2944 self.sessions
2945 .submit_cluster_job(&coordinator, request, timeout)
2946 .await
2947 .map_err(session_error)
2948 }
2949
2950 async fn list_cluster_jobs_inner(&self, timeout: Duration) -> Result<ClusterJobList, DcpError> {
2951 if !self.is_local_coordinator() {
2952 let coordinator = self.coordinator_node_id()?;
2953 return self
2954 .sessions
2955 .list_cluster_jobs(&coordinator, timeout)
2956 .await
2957 .map_err(session_error);
2958 }
2959
2960 let timeout = if timeout.is_zero() {
2961 self.sessions.inner.config.request_timeout
2962 } else {
2963 timeout
2964 };
2965 let snapshot = self.state.get();
2966 let local_member = snapshot.member(&self.self_node).cloned();
2967 let local_jobs = list_local_jobs(self.registry.clone()).await?;
2968 let mut nodes = Vec::new();
2969 nodes.push(ClusterJobNode {
2970 node_id: self.self_node.clone(),
2971 address: local_member
2972 .as_ref()
2973 .map(|member| member.address.to_string())
2974 .unwrap_or_default(),
2975 local: true,
2976 jobs: local_jobs,
2977 });
2978
2979 let mut errors = Vec::new();
2980 let mut pending = Vec::new();
2981 for member in snapshot.members.values() {
2982 if member.node_id == self.self_node || !member.has_role(&self.agent_role) {
2983 continue;
2984 }
2985 if member.state == MemberState::Removed {
2986 continue;
2987 }
2988 if member.state != MemberState::Up || member.unreachable {
2989 errors.push(ClusterNodeError {
2990 node_id: member.node_id.clone(),
2991 message: if member.unreachable {
2992 "member is unreachable".to_owned()
2993 } else {
2994 format!("member is {:?}", member.state)
2995 },
2996 });
2997 continue;
2998 }
2999 let node_id = member.node_id.clone();
3000 let address = member.address.to_string();
3001 let sessions = self.sessions.clone();
3002 pending.push(tokio::spawn(async move {
3003 let result = sessions.list_jobs(&node_id, timeout).await;
3004 (node_id, address, result)
3005 }));
3006 }
3007
3008 let deadline = Instant::now() + timeout;
3009 for task in pending {
3010 let remaining = deadline.saturating_duration_since(Instant::now());
3011 match tokio::time::timeout(remaining, task).await {
3012 Ok(Ok((node_id, address, Ok(jobs)))) => nodes.push(ClusterJobNode {
3013 node_id,
3014 address,
3015 local: false,
3016 jobs,
3017 }),
3018 Ok(Ok((node_id, _address, Err(message)))) => {
3019 errors.push(ClusterNodeError { node_id, message });
3020 }
3021 Ok(Err(error)) => errors.push(ClusterNodeError {
3022 node_id: "unknown".to_owned(),
3023 message: format!("cluster fan-out task failed: {error}"),
3024 }),
3025 Err(_) => errors.push(ClusterNodeError {
3026 node_id: "unknown".to_owned(),
3027 message: "cluster fan-out timed out".to_owned(),
3028 }),
3029 }
3030 }
3031
3032 nodes.sort_by(|left, right| left.node_id.cmp(&right.node_id));
3033 errors.sort_by(|left, right| left.node_id.cmp(&right.node_id));
3034 Ok(ClusterJobList {
3035 partial: !errors.is_empty(),
3036 nodes,
3037 errors,
3038 })
3039 }
3040
3041 async fn cluster_node_info_inner(
3042 &self,
3043 _timeout: Duration,
3044 ) -> Result<ClusterNodeList, DcpError> {
3045 let snapshot = self.state.get();
3046 let session_states = self.sessions.session_states().await;
3047 let mut nodes = snapshot
3048 .members
3049 .values()
3050 .map(|member| ClusterNodeStatus {
3051 node_id: member.node_id.clone(),
3052 member_state: format!("{:?}", member.state),
3053 address: member.address.to_string(),
3054 agent_addr: member
3055 .agent_addr
3056 .map(|addr| addr.to_string())
3057 .unwrap_or_default(),
3058 roles: member.roles.clone(),
3059 unreachable: member.unreachable,
3060 local: member.node_id == self.self_node,
3061 session_state: if member.node_id == self.self_node {
3062 "local".to_owned()
3063 } else {
3064 session_states
3065 .get(&member.node_id)
3066 .cloned()
3067 .unwrap_or_else(|| "not_connected".to_owned())
3068 },
3069 })
3070 .collect::<Vec<_>>();
3071 nodes.sort_by(|left, right| left.node_id.cmp(&right.node_id));
3072
3073 let mut errors = nodes
3074 .iter()
3075 .filter(|node| !node.local)
3076 .filter(|node| node.roles.iter().any(|role| role == &self.agent_role))
3077 .filter(|node| {
3078 node.member_state == "Up" && !node.unreachable && node.session_state != "connected"
3079 })
3080 .map(|node| ClusterNodeError {
3081 node_id: node.node_id.clone(),
3082 message: format!("node session {}", node.session_state),
3083 })
3084 .collect::<Vec<_>>();
3085 errors.sort_by(|left, right| left.node_id.cmp(&right.node_id));
3086
3087 Ok(ClusterNodeList {
3088 partial: !errors.is_empty(),
3089 nodes,
3090 errors,
3091 })
3092 }
3093
3094 async fn cluster_job_status_inner(
3095 &self,
3096 name: String,
3097 timeout: Duration,
3098 ) -> Result<crate::dcp::proto::JobStatus, DcpError> {
3099 if self.is_local_coordinator() {
3100 return self.placement.cluster_job_status(name, timeout).await;
3101 }
3102 let coordinator = self.coordinator_node_id()?;
3103 self.sessions
3104 .forward_cluster_job_status(&coordinator, name, timeout)
3105 .await
3106 .map_err(session_error)
3107 }
3108
3109 async fn drain_cluster_job_inner(
3110 &self,
3111 name: String,
3112 timeout: Duration,
3113 ) -> Result<crate::dcp::proto::JobStatus, DcpError> {
3114 if self.is_local_coordinator() {
3115 return self.placement.drain_cluster_job(name, timeout).await;
3116 }
3117 let coordinator = self.coordinator_node_id()?;
3118 self.sessions
3119 .forward_drain_cluster_job(&coordinator, name, timeout)
3120 .await
3121 .map_err(session_error)
3122 }
3123
3124 async fn stop_cluster_job_inner(
3125 &self,
3126 name: String,
3127 timeout: Duration,
3128 ) -> Result<crate::dcp::proto::JobStatus, DcpError> {
3129 if self.is_local_coordinator() {
3130 return self.placement.stop_cluster_job(name, timeout).await;
3131 }
3132 let coordinator = self.coordinator_node_id()?;
3133 self.sessions
3134 .forward_stop_cluster_job(&coordinator, name, timeout)
3135 .await
3136 .map_err(session_error)
3137 }
3138
3139 async fn remember_cluster_assignment_inner(
3140 &self,
3141 request: RememberClusterAssignment,
3142 ) -> Result<(), DcpError> {
3143 self.placement
3144 .remember_cluster_assignment(request.instance_name, request.assignment)
3145 .await
3146 }
3147
3148 fn is_local_coordinator(&self) -> bool {
3149 self.state.get().is_placement_coordinator(&self.self_node)
3150 }
3151
3152 fn coordinator_node_id(&self) -> Result<String, DcpError> {
3153 self.state
3154 .get()
3155 .placement_coordinator()
3156 .map(|member| member.node_id.clone())
3157 .ok_or_else(|| {
3158 DcpError::response(
3159 ResponseStatus::Failed,
3160 "no placement coordinator is available",
3161 )
3162 })
3163 }
3164}
3165
3166async fn list_local_jobs(
3167 registry: JobRegistryHandle,
3168) -> Result<Vec<crate::dcp::proto::JobStatus>, DcpError> {
3169 let statuses = tokio::task::spawn_blocking(move || registry.list()).await??;
3170 Ok(statuses.iter().map(wire_job_status).collect())
3171}
3172
3173fn validate_cluster_agent_config(config: &ClusterAgentConfig) -> ClusterAgentResult<()> {
3174 if config.sessions.agent_role.trim().is_empty() {
3175 return Err(ClusterAgentError::InvalidConfig(
3176 "sessions.agent_role must not be empty".to_owned(),
3177 ));
3178 }
3179 if config.sessions.reconnect_min_backoff.is_zero()
3180 || config.sessions.reconnect_max_backoff < config.sessions.reconnect_min_backoff
3181 {
3182 return Err(ClusterAgentError::InvalidConfig(
3183 "node-session reconnect backoff must be non-zero and ordered".to_owned(),
3184 ));
3185 }
3186 match config.sessions.transport {
3187 NodeSessionTransport::TcpLoopback if config.dcp.tcp.is_none() => {
3188 Err(ClusterAgentError::InvalidConfig(
3189 "node-session TCP transport requires a DCP TCP listener".to_owned(),
3190 ))
3191 }
3192 NodeSessionTransport::QuicMtls { .. } if config.dcp.quic.is_none() => {
3193 Err(ClusterAgentError::InvalidConfig(
3194 "node-session QUIC transport requires a DCP QUIC listener".to_owned(),
3195 ))
3196 }
3197 _ => Ok(()),
3198 }
3199}
3200
3201fn advertised_agent_addr(
3202 transport: &NodeSessionTransport,
3203 handle: &DcpServerHandle,
3204) -> ClusterAgentResult<SocketAddr> {
3205 match transport {
3206 NodeSessionTransport::TcpLoopback => handle.tcp_addr().ok_or_else(|| {
3207 ClusterAgentError::InvalidConfig("DCP TCP listener did not start".to_owned())
3208 }),
3209 NodeSessionTransport::QuicMtls { .. } => handle.quic_addr().ok_or_else(|| {
3210 ClusterAgentError::InvalidConfig("DCP QUIC listener did not start".to_owned())
3211 }),
3212 }
3213}
3214
3215fn ensure_agent_role(config: &mut ClusterConfig, role: &str) {
3216 if !config.roles.iter().any(|candidate| candidate == role) {
3217 config.roles.push(role.to_owned());
3218 }
3219}
3220
3221#[cfg(test)]
3222mod tests {
3223 use super::*;
3224
3225 #[test]
3226 fn remembered_assignment_generation_is_monotonic() {
3227 let mut assignments = BTreeMap::new();
3228 assert!(remember_assignment(
3229 &mut assignments,
3230 "job".to_owned(),
3231 assignment(2, "current-owner"),
3232 ));
3233
3234 assert!(!remember_assignment(
3235 &mut assignments,
3236 "job".to_owned(),
3237 assignment(1, "stale-owner"),
3238 ));
3239 assert_eq!(assignments["job"].assigned_node, "current-owner");
3240 assert_eq!(assignments["job"].placement_generation, 2);
3241
3242 assert!(remember_assignment(
3243 &mut assignments,
3244 "job".to_owned(),
3245 assignment(2, "equal-generation-owner"),
3246 ));
3247 assert_eq!(assignments["job"].assigned_node, "equal-generation-owner");
3248
3249 assert!(remember_assignment(
3250 &mut assignments,
3251 "job".to_owned(),
3252 assignment(3, "new-owner"),
3253 ));
3254 assert_eq!(assignments["job"].assigned_node, "new-owner");
3255 assert_eq!(assignments["job"].placement_generation, 3);
3256 }
3257
3258 fn assignment(placement_generation: u64, assigned_node: &str) -> ClusterJobMetadata {
3259 ClusterJobMetadata {
3260 factory_name: "ticker".to_owned(),
3261 params: BTreeMap::new(),
3262 placement: RegistryPlacementSpec::least_jobs(None),
3263 coordinator_node: "coordinator".to_owned(),
3264 assigned_node: assigned_node.to_owned(),
3265 placement_generation,
3266 history: Vec::new(),
3267 }
3268 }
3269}