Skip to main content

hydro_lang/deploy/
deploy_runtime_containerized.rs

1#![allow(
2    unused,
3    reason = "unused in trybuild but the __staged version is needed"
4)]
5#![allow(missing_docs, reason = "used internally")]
6
7use std::collections::HashMap;
8use std::future::Future;
9use std::net::SocketAddr;
10use std::ops::{Deref, DerefMut};
11use std::pin::Pin;
12use std::sync::Arc;
13use std::task::{Context, Poll};
14use std::time::Duration;
15
16use bytes::BytesMut;
17use futures::{FutureExt, Sink, SinkExt, Stream, StreamExt};
18use proc_macro2::Span;
19use sinktools::demux_map_lazy::LazyDemuxSink;
20use sinktools::lazy::{LazySink, LazySource};
21use sinktools::lazy_sink_source::LazySinkSource;
22use stageleft::runtime_support::{
23    FreeVariableWithContext, FreeVariableWithContextWithProps, QuoteTokens,
24};
25use stageleft::{QuotedWithContext, q};
26use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
27use tokio::net::{TcpListener, TcpStream};
28use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
29use tracing::{debug, instrument, warn};
30
31use crate::location::dynamic::LocationId;
32use crate::location::member_id::TaglessMemberId;
33use crate::location::{LocationKey, MemberId, MembershipEvent};
34
35/// The single well-known port that every node listens on.
36pub const CHANNEL_MUX_PORT: u16 = 10000;
37
38/// Magic constant embedded in every [`ChannelMagic`] header.
39pub const CHANNEL_MAGIC: u64 = 0x4859_4452_4F5F_4348;
40
41/// Magic header sent as the very first frame of every channel handshake.
42///
43/// This is a fixed value that never changes across versions, used to confirm
44/// both sides are speaking the same protocol family before anything else.
45#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
46pub struct ChannelMagic {
47    pub magic: u64,
48}
49
50/// Current protocol version for the channel handshake.
51pub const CHANNEL_PROTOCOL_VERSION: u64 = 1;
52
53/// Protocol version sent as the second frame, after [`ChannelMagic`].
54///
55/// Incremented when the handshake format changes. The receiver checks this
56/// to decide how to deserialize the subsequent [`ChannelHandshake`] frame.
57#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
58pub struct ChannelProtocolVersion {
59    pub version: u64,
60}
61
62/// Handshake message sent by the connecting side to identify the channel.
63///
64/// The receiver reads the third frame (after [`ChannelMagic`] and
65/// [`ChannelProtocolVersion`]) to know which logical channel the connection
66/// belongs to, and optionally which cluster member is connecting.
67/// cluster member is connecting.
68#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
69pub struct ChannelHandshake {
70    /// The logical channel name for this connection.
71    pub channel_name: String,
72    /// If the sender is a cluster member, this is its identifier
73    /// (container name for Docker, task ID for ECS, etc.).
74    /// `None` for process-to-process connections.
75    pub sender_id: Option<String>,
76}
77
78/// A dispatched channel connection: optional sender ID and the read stream.
79type MuxConnection = (
80    Option<String>,
81    FramedRead<OwnedReadHalf, LengthDelimitedCodec>,
82);
83
84/// A shared accept loop that listens on a single port and dispatches
85/// incoming connections to the right consumer based on the channel name
86/// sent in the handshake.
87///
88/// Each node creates one of these at startup. Individual channels register
89/// themselves and receive their connection via a mpsc channel.
90pub struct ChannelMux {
91    /// Map from channel name to a sender that delivers accepted connections.
92    channels: std::sync::Mutex<HashMap<String, tokio::sync::mpsc::UnboundedSender<MuxConnection>>>,
93}
94
95impl Default for ChannelMux {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101impl ChannelMux {
102    pub fn new() -> Self {
103        Self {
104            channels: std::sync::Mutex::new(HashMap::new()),
105        }
106    }
107
108    pub fn register(
109        &self,
110        channel_name: String,
111    ) -> tokio::sync::mpsc::UnboundedReceiver<MuxConnection> {
112        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
113        let mut channels = self.channels.lock().unwrap();
114        channels.insert(channel_name, tx);
115        rx
116    }
117
118    pub async fn run_accept_loop(self: Arc<Self>, listener: TcpListener) {
119        loop {
120            let (stream, peer) = match listener.accept().await {
121                Ok(v) => v,
122                Err(e) => {
123                    warn!(name: "accept_error", error = %e);
124                    continue;
125                }
126            };
127            debug!(name: "mux_accepting", ?peer);
128
129            let mux = self.clone();
130            tokio::spawn(async move {
131                // Accepted streams are read-only; if a write path is ever added,
132                // set TCP_NODELAY first (see connect_channel).
133                let (rx, _tx) = stream.into_split();
134                let mut source = FramedRead::new(rx, LengthDelimitedCodec::new());
135
136                let Some(Ok(magic_frame)) = source.next().await else {
137                    warn!(name: "magic_failed", ?peer, "no magic frame");
138                    return;
139                };
140
141                let magic: ChannelMagic = match bincode::deserialize(&magic_frame) {
142                    Ok(m) => m,
143                    Err(e) => {
144                        warn!(name: "magic_deserialize_failed", ?peer, error = %e);
145                        return;
146                    }
147                };
148
149                if magic.magic != CHANNEL_MAGIC {
150                    warn!(name: "bad_magic", ?peer, magic = magic.magic, expected = CHANNEL_MAGIC);
151                    return;
152                }
153
154                let Some(Ok(version_frame)) = source.next().await else {
155                    warn!(name: "version_failed", ?peer, "no version frame");
156                    return;
157                };
158
159                let version: ChannelProtocolVersion = match bincode::deserialize(&version_frame) {
160                    Ok(v) => v,
161                    Err(e) => {
162                        warn!(name: "version_deserialize_failed", ?peer, error = %e);
163                        return;
164                    }
165                };
166
167                if version.version != CHANNEL_PROTOCOL_VERSION {
168                    warn!(name: "version_mismatch", ?peer, version = version.version, expected = CHANNEL_PROTOCOL_VERSION);
169                    return;
170                }
171
172                let Some(Ok(handshake_frame)) = source.next().await else {
173                    warn!(name: "handshake_failed", ?peer, "no handshake frame");
174                    return;
175                };
176
177                let handshake: ChannelHandshake = match bincode::deserialize(&handshake_frame) {
178                    Ok(h) => h,
179                    Err(e) => {
180                        warn!(name: "handshake_deserialize_failed", ?peer, error = %e);
181                        return;
182                    }
183                };
184
185                debug!(name: "handshake_received", ?peer, ?handshake);
186
187                let channels = mux.channels.lock().unwrap();
188                if let Some(tx_chan) = channels.get(&handshake.channel_name) {
189                    let _ = tx_chan.send((handshake.sender_id, source));
190                } else {
191                    warn!(
192                        name: "unknown_channel",
193                        channel_name = %handshake.channel_name,
194                        ?peer,
195                        "no registered consumer for channel"
196                    );
197                }
198            });
199        }
200    }
201}
202
203/// Get or initialize the global ChannelMux for this process.
204///
205/// The first call creates the TcpListener and spawns the accept loop.
206/// Subsequent calls return the same `Arc<ChannelMux>`.
207pub fn get_or_init_channel_mux() -> Arc<ChannelMux> {
208    use std::sync::OnceLock;
209    static MUX: OnceLock<Arc<ChannelMux>> = OnceLock::new();
210
211    MUX.get_or_init(|| {
212        let mux = Arc::new(ChannelMux::new());
213        let mux_clone = mux.clone();
214
215        // Spawn the accept loop in a background task.
216        // We use tokio::spawn which requires a runtime to be active.
217        tokio::spawn(async move {
218            let bind_addr = format!("0.0.0.0:{}", CHANNEL_MUX_PORT);
219            debug!(name: "mux_listening", %bind_addr);
220            let listener = TcpListener::bind(&bind_addr)
221                .await
222                .expect("failed to bind channel mux listener");
223            mux_clone.run_accept_loop(listener).await;
224        });
225
226        mux
227    })
228    .clone()
229}
230
231/// Sends a [`ChannelMagic`], then a [`ChannelProtocolVersion`], then a
232/// [`ChannelHandshake`] as three separate frames over the given sink.
233pub async fn send_handshake(
234    sink: &mut FramedWrite<TcpStream, LengthDelimitedCodec>,
235    channel_name: &str,
236    sender_id: Option<&str>,
237) -> Result<(), std::io::Error> {
238    let magic = ChannelMagic {
239        magic: CHANNEL_MAGIC,
240    };
241    sink.send(bytes::Bytes::from(bincode::serialize(&magic).unwrap()))
242        .await?;
243
244    let version = ChannelProtocolVersion {
245        version: CHANNEL_PROTOCOL_VERSION,
246    };
247    sink.send(bytes::Bytes::from(bincode::serialize(&version).unwrap()))
248        .await?;
249
250    let handshake = ChannelHandshake {
251        channel_name: channel_name.to_owned(),
252        sender_id: sender_id.map(|s| s.to_owned()),
253    };
254    sink.send(bytes::Bytes::from(bincode::serialize(&handshake).unwrap()))
255        .await?;
256    Ok(())
257}
258
259/// Connects to a channel endpoint, enabling `TCP_NODELAY` so Nagle's
260/// algorithm (combined with delayed ACKs) doesn't stall small writes.
261/// Failure to set the option is non-fatal, so we just log a warning.
262pub async fn connect_channel(target: &str) -> Result<TcpStream, std::io::Error> {
263    let stream = TcpStream::connect(target).await?;
264    if let Err(e) = stream.set_nodelay(true) {
265        warn!(name: "set_nodelay_failed", %target, error = %e);
266    }
267    Ok(stream)
268}
269
270pub fn deploy_containerized_o2o(target: &str, channel_name: &str) -> (syn::Expr, syn::Expr) {
271    (
272        q!(LazySink::<_, _, _, bytes::Bytes>::new(move || Box::pin(
273            async move {
274                let channel_name = channel_name;
275                let target = format!("{}:{}", target, self::CHANNEL_MUX_PORT);
276                debug!(name: "connecting", %target, %channel_name);
277
278                let stream = self::connect_channel(&target).await?;
279                let mut sink = FramedWrite::new(stream, LengthDelimitedCodec::new());
280
281                self::send_handshake(&mut sink, channel_name, None).await?;
282
283                Result::<_, std::io::Error>::Ok(sink)
284            }
285        )))
286        .splice_untyped_ctx(&()),
287        q!(LazySource::new(move || Box::pin(async move {
288            let channel_name = channel_name;
289            let mux = self::get_or_init_channel_mux();
290            let mut rx = mux.register(channel_name.to_owned());
291
292            let (_sender_id, source) = rx.recv().await.ok_or_else(|| {
293                std::io::Error::new(std::io::ErrorKind::ConnectionReset, "channel mux closed")
294            })?;
295
296            debug!(name: "o2o_channel_connected", %channel_name);
297
298            Result::<_, std::io::Error>::Ok(source)
299        })))
300        .splice_untyped_ctx(&()),
301    )
302}
303
304pub fn deploy_containerized_o2m(channel_name: &str) -> (syn::Expr, syn::Expr) {
305    (
306        q!(sinktools::demux_map_lazy::<_, _, _, _>(
307            move |key: &TaglessMemberId| {
308                let key = key.clone();
309                let channel_name = channel_name.to_owned();
310
311                LazySink::<_, _, _, bytes::Bytes>::new(move || {
312                    Box::pin(async move {
313                        let target =
314                            format!("{}:{}", key.get_container_name(), self::CHANNEL_MUX_PORT);
315                        debug!(name: "connecting", %target, channel_name = %channel_name);
316
317                        let stream = self::connect_channel(&target).await?;
318                        let mut sink = FramedWrite::new(stream, LengthDelimitedCodec::new());
319
320                        self::send_handshake(&mut sink, &channel_name, None).await?;
321
322                        Result::<_, std::io::Error>::Ok(sink)
323                    })
324                })
325            }
326        ))
327        .splice_untyped_ctx(&()),
328        q!(LazySource::new(move || Box::pin(async move {
329            let channel_name = channel_name;
330            let mux = self::get_or_init_channel_mux();
331            let mut rx = mux.register(channel_name.to_owned());
332
333            let (_sender_id, source) = rx.recv().await.ok_or_else(|| {
334                std::io::Error::new(std::io::ErrorKind::ConnectionReset, "channel mux closed")
335            })?;
336
337            debug!(name: "o2m_channel_connected", %channel_name);
338
339            Result::<_, std::io::Error>::Ok(source)
340        })))
341        .splice_untyped_ctx(&()),
342    )
343}
344
345pub fn deploy_containerized_m2o(target_host: &str, channel_name: &str) -> (syn::Expr, syn::Expr) {
346    (
347        q!(LazySink::<_, _, _, bytes::Bytes>::new(move || {
348            Box::pin(async move {
349                let channel_name = channel_name;
350                let target = format!("{}:{}", target_host, self::CHANNEL_MUX_PORT);
351                debug!(name: "connecting", %target, %channel_name);
352
353                let stream = self::connect_channel(&target).await?;
354                let mut sink = FramedWrite::new(stream, LengthDelimitedCodec::new());
355
356                let container_name = std::env::var("CONTAINER_NAME").unwrap();
357                self::send_handshake(&mut sink, channel_name, Some(&container_name)).await?;
358
359                Result::<_, std::io::Error>::Ok(sink)
360            })
361        }))
362        .splice_untyped_ctx(&()),
363        q!(LazySource::new(move || Box::pin(async move {
364            let channel_name = channel_name;
365            let mux = self::get_or_init_channel_mux();
366            let mut rx = mux.register(channel_name.to_owned());
367
368            Result::<_, std::io::Error>::Ok(
369                futures::stream::unfold(rx, |mut rx| {
370                    Box::pin(async move {
371                        let (sender_id, source) = rx.recv().await?;
372                        let from = sender_id.expect("m2o sender must provide container name");
373
374                        debug!(name: "m2o_channel_connected", %from);
375
376                        Some((
377                            source.map(move |v| {
378                                v.map(|v| (TaglessMemberId::from_container_name(from.clone()), v))
379                            }),
380                            rx,
381                        ))
382                    })
383                })
384                .flatten_unordered(None),
385            )
386        })))
387        .splice_untyped_ctx(&()),
388    )
389}
390
391pub fn deploy_containerized_m2m(channel_name: &str) -> (syn::Expr, syn::Expr) {
392    (
393        q!(sinktools::demux_map_lazy::<_, _, _, _>(
394            move |key: &TaglessMemberId| {
395                let key = key.clone();
396                let channel_name = channel_name.to_owned();
397
398                LazySink::<_, _, _, bytes::Bytes>::new(move || {
399                    Box::pin(async move {
400                        let target =
401                            format!("{}:{}", key.get_container_name(), self::CHANNEL_MUX_PORT);
402                        debug!(name: "connecting", %target, channel_name = %channel_name);
403
404                        let stream = self::connect_channel(&target).await?;
405                        let mut sink = FramedWrite::new(stream, LengthDelimitedCodec::new());
406
407                        let container_name = std::env::var("CONTAINER_NAME").unwrap();
408                        self::send_handshake(&mut sink, &channel_name, Some(&container_name))
409                            .await?;
410
411                        Result::<_, std::io::Error>::Ok(sink)
412                    })
413                })
414            }
415        ))
416        .splice_untyped_ctx(&()),
417        q!(LazySource::new(move || Box::pin(async move {
418            let channel_name = channel_name;
419            let mux = self::get_or_init_channel_mux();
420            let mut rx = mux.register(channel_name.to_owned());
421
422            Result::<_, std::io::Error>::Ok(
423                futures::stream::unfold(rx, |mut rx| {
424                    Box::pin(async move {
425                        let (sender_id, source) = rx.recv().await?;
426                        let from = sender_id.expect("m2m sender must provide container name");
427
428                        debug!(name: "m2m_channel_connected", %from);
429
430                        Some((
431                            source.map(move |v| {
432                                v.map(|v| (TaglessMemberId::from_container_name(from.clone()), v))
433                            }),
434                            rx,
435                        ))
436                    })
437                })
438                .flatten_unordered(None),
439            )
440        })))
441        .splice_untyped_ctx(&()),
442    )
443}
444
445pub struct SocketIdent {
446    pub socket_ident: syn::Ident,
447}
448
449impl<Ctx> FreeVariableWithContextWithProps<Ctx, ()> for SocketIdent {
450    type O = TcpListener;
451
452    fn to_tokens(self, _ctx: &Ctx) -> (QuoteTokens, ())
453    where
454        Self: Sized,
455    {
456        let ident = self.socket_ident;
457
458        (
459            QuoteTokens {
460                prelude: None,
461                expr: Some(quote::quote! { #ident }),
462            },
463            (),
464        )
465    }
466}
467
468pub fn deploy_containerized_external_sink_source_ident(socket_ident: syn::Ident) -> syn::Expr {
469    let socket_ident = SocketIdent { socket_ident };
470
471    q!(LazySinkSource::<
472        _,
473        FramedRead<OwnedReadHalf, LengthDelimitedCodec>,
474        FramedWrite<OwnedWriteHalf, LengthDelimitedCodec>,
475        bytes::Bytes,
476        std::io::Error,
477    >::new(async move {
478        let (stream, peer) = socket_ident.accept().await?;
479        debug!(name: "external accepting", ?peer);
480        let (rx, tx) = stream.into_split();
481
482        let fr = FramedRead::new(rx, LengthDelimitedCodec::new());
483        let fw = FramedWrite::new(tx, LengthDelimitedCodec::new());
484
485        Result::<_, std::io::Error>::Ok((fr, fw))
486    },))
487    .splice_untyped_ctx(&())
488}
489
490pub fn cluster_ids<'a>() -> impl QuotedWithContext<'a, &'a [TaglessMemberId], ()> + Clone {
491    // unimplemented!(); // this is unused.
492
493    // This is a dummy piece of code, since clusters are dynamic when containerized.
494    q!(Box::leak(Box::new([TaglessMemberId::from_container_name(
495        "INVALID CONTAINER NAME cluster_ids"
496    )]))
497    .as_slice())
498}
499
500#[cfg(feature = "docker_runtime")]
501pub fn cluster_self_id<'a>() -> impl QuotedWithContext<'a, TaglessMemberId, ()> + Clone + 'a {
502    q!(TaglessMemberId::from_container_name(
503        std::env::var("CONTAINER_NAME").unwrap()
504    ))
505}
506
507#[cfg(feature = "docker_runtime")]
508pub fn cluster_membership_stream<'a>(
509    location_id: &LocationId,
510) -> impl QuotedWithContext<'a, Box<dyn Stream<Item = (TaglessMemberId, MembershipEvent)> + Unpin>, ()>
511{
512    let key = location_id.key();
513
514    q!(Box::new(self::docker_membership_stream(
515        std::env::var("DEPLOYMENT_INSTANCE").unwrap(),
516        key
517    ))
518        as Box<
519            dyn Stream<Item = (TaglessMemberId, MembershipEvent)> + Unpin,
520        >)
521}
522
523#[cfg(feature = "docker_runtime")]
524// There's a risk of race conditions here since all the containers will be starting up at the same time.
525// So we need to start listening for events and the take a snapshot of currently running containers, since they may have already started up before we started listening to events.
526// Then we need to turn that into a usable stream for the consumer in this current hydro program. The way you do that is by emitting from the snapshot first, and then start emitting from the stream. Keep a hash set around to track whether a container is up or down.
527#[instrument(skip_all, fields(%deployment_instance, %location_key))]
528fn docker_membership_stream(
529    deployment_instance: String,
530    location_key: LocationKey,
531) -> impl Stream<Item = (TaglessMemberId, MembershipEvent)> + Unpin {
532    use std::collections::HashSet;
533    use std::sync::{Arc, Mutex};
534
535    use bollard::Docker;
536    use bollard::query_parameters::{EventsOptions, ListContainersOptions};
537    use tokio::sync::mpsc;
538
539    let docker = Docker::connect_with_local_defaults()
540        .unwrap()
541        .with_timeout(Duration::from_secs(1));
542
543    let (event_tx, event_rx) = mpsc::unbounded_channel::<(String, MembershipEvent)>();
544
545    // 1. Start event subscription in a spawned task
546    let events_docker = docker.clone();
547    let events_deployment_instance = deployment_instance.clone();
548    tokio::spawn(async move {
549        let mut filters = HashMap::new();
550        filters.insert("type".to_owned(), vec!["container".to_owned()]);
551        filters.insert(
552            "event".to_owned(),
553            vec!["start".to_owned(), "die".to_owned()],
554        );
555        let event_options = Some(EventsOptions {
556            filters: Some(filters),
557            ..Default::default()
558        });
559
560        let mut events = events_docker.events(event_options);
561        while let Some(event) = events.next().await {
562            if let Some((name, membership_event)) = event.ok().and_then(|e| {
563                let name = e
564                    .actor
565                    .as_ref()
566                    .and_then(|a| a.attributes.as_ref())
567                    .and_then(|attrs| attrs.get("name"))
568                    .map(|s| &**s)?;
569
570                if name.contains(format!("{events_deployment_instance}-{location_key}").as_str()) {
571                    match e.action.as_deref() {
572                        Some("start") => Some((name.to_owned(), MembershipEvent::Joined)),
573                        Some("die") => Some((name.to_owned(), MembershipEvent::Left)),
574                        _ => None,
575                    }
576                } else {
577                    None
578                }
579            }) && event_tx.send((name, membership_event)).is_err()
580            {
581                break;
582            }
583        }
584    });
585
586    // Shared state for deduplication across snapshot and events phases
587    let seen_joined = Arc::new(Mutex::new(HashSet::<String>::new()));
588    let seen_joined_snapshot = seen_joined.clone();
589    let seen_joined_events = seen_joined;
590
591    // 2. Snapshot stream - fetch current containers and emit Joined events
592    let snapshot_stream = futures::stream::once(async move {
593        let mut filters = HashMap::new();
594        filters.insert(
595            "name".to_owned(),
596            vec![format!("{deployment_instance}-{location_key}")],
597        );
598        let options = Some(ListContainersOptions {
599            filters: Some(filters),
600            ..Default::default()
601        });
602
603        docker
604            .list_containers(options)
605            .await
606            .unwrap_or_default()
607            .iter()
608            .filter_map(|c| c.names.as_deref())
609            .filter_map(|names| names.first())
610            .map(|name| name.trim_start_matches('/'))
611            .filter(|&name| seen_joined_snapshot.lock().unwrap().insert(name.to_owned()))
612            .map(|name| (name.to_owned(), MembershipEvent::Joined))
613            .collect::<Vec<_>>()
614    })
615    .flat_map(futures::stream::iter);
616
617    // 3. Events stream - process live events with deduplication
618    let events_stream = tokio_stream::StreamExt::filter_map(
619        tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx),
620        move |(name, event)| {
621            let mut seen = seen_joined_events.lock().unwrap();
622            match event {
623                MembershipEvent::Joined => {
624                    if seen.insert(name.clone()) {
625                        Some((name, MembershipEvent::Joined))
626                    } else {
627                        None
628                    }
629                }
630                MembershipEvent::Left => seen.take(&name).map(|name| (name, MembershipEvent::Left)),
631            }
632        },
633    );
634
635    // 4. Chain snapshot then events
636    Box::pin(
637        snapshot_stream
638            .chain(events_stream)
639            .map(|(k, v)| (TaglessMemberId::from_container_name(k), v))
640            .inspect(|(member_id, event)| debug!(name: "membership_event", ?member_id, ?event)),
641    )
642}