Skip to main content

datum_agent/
cluster.rs

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
37/// Role used by cluster-aware agents to discover peers.
38pub const AGENT_ROLE: &str = "agent";
39
40/// Result type for cluster-aware agent startup and shutdown.
41pub type ClusterAgentResult<T> = Result<T, ClusterAgentError>;
42
43/// Errors returned by the cluster-aware agent entrypoint.
44#[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/// Transport used for node-to-node DCP sessions.
57#[derive(Clone)]
58pub enum NodeSessionTransport {
59    /// Plaintext TCP is accepted only for loopback development and tests.
60    TcpLoopback,
61    /// QUIC with mTLS. The same client config is used for every peer.
62    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/// Configuration for DCP node sessions maintained by a cluster-aware agent.
81#[derive(Clone, Debug)]
82pub struct NodeSessionConfig {
83    /// Role that marks a member as running a DCP agent endpoint.
84    pub agent_role: String,
85    /// Node-to-node DCP transport.
86    pub transport: NodeSessionTransport,
87    /// Initial reconnect backoff.
88    pub reconnect_min_backoff: Duration,
89    /// Maximum reconnect backoff.
90    pub reconnect_max_backoff: Duration,
91    /// Default per-peer fan-out timeout.
92    pub request_timeout: Duration,
93    /// Bounded per-session command queue capacity.
94    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/// Combined configuration for one cluster-aware Datum agent.
111#[derive(Clone, Default)]
112pub struct ClusterAgentConfig {
113    /// Local job registry configuration.
114    pub agent: AgentConfig,
115    /// Membership configuration.
116    pub cluster: ClusterConfig,
117    /// DCP listener configuration.
118    pub dcp: DcpServerConfig,
119    /// Node-session configuration.
120    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
135/// Entrypoint for starting membership, the local agent, DCP, and node sessions
136/// together.
137pub 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
191/// Running cluster-aware agent.
192pub 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        // Live registries are authoritative for both running intent and stopped tombstones. The
2036        // new coordinator's replicated table is retained for jobs whose owning node is already
2037        // down and therefore cannot participate in this handoff scan. Peer replica maps are not
2038        // exchanged here, so a dead-owner job is recoverable only when its upsert reached the
2039        // specific node that adopts coordination.
2040        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        // Adoption is deliberately fail-closed until every member still considered Up responds.
2351        // An unresponsive former coordinator or peer delays adoption until the failure detector
2352        // marks it Down (bounded by suspect_timeout), which prevents a surviving job from being
2353        // omitted. A genuinely Up but slow peer, such as one in a GC pause, defers adoption too.
2354        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                    &current_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                    &current_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}