Skip to main content

hydro_lang/location/
cluster.rs

1//! Definitions for clusters, which represent a group of identical processes.
2//!
3//! A [`Cluster`] is a multi-node location in the Hydro distributed programming model.
4//! Unlike a [`super::Process`], which maps to a single machine, a cluster represents
5//! a dynamically-sized set of machines that all run the same code. Each member of the
6//! cluster is assigned a unique [`super::MemberId`] that can be used to address it.
7//!
8//! Clusters are useful for parallelism, replication, and sharding patterns. Data can
9//! be broadcast to all members, sent to a specific member by ID, or scattered across
10//! members.
11
12use std::fmt::{Debug, Formatter};
13use std::marker::PhantomData;
14
15use proc_macro2::Span;
16use quote::quote;
17use stageleft::runtime_support::{FreeVariableWithContextWithProps, QuoteTokens};
18use stageleft::{QuotedWithContextWithProps, quote_type};
19
20use super::dynamic::LocationId;
21use super::{Location, MemberId};
22use crate::compile::builder::FlowState;
23use crate::location::LocationKey;
24use crate::location::dynamic::ClusterConsistency;
25use crate::location::member_id::TaglessMemberId;
26use crate::staging_util::{Invariant, get_this_crate};
27
28/// A marker trait for levels of consistency that can be guaranteed for a live collection placed
29/// across members of a cluster.
30pub trait Consistency {
31    /// Gets the runtime enum variant associated with this consistency level.
32    fn consistency() -> ClusterConsistency;
33}
34
35/// No consistency is guaranteed across cluster members, which means that the live collection
36/// may take on arbitrarily different values across members.
37pub enum NoConsistency {}
38impl Consistency for NoConsistency {
39    fn consistency() -> ClusterConsistency {
40        ClusterConsistency::NoConsistency
41    }
42}
43
44/// Eventual consistency is guaranteed across cluster members, which means that at steady-state
45/// the live collection will always resolve to the same value across all members of the cluster.
46pub enum EventualConsistency {}
47impl Consistency for EventualConsistency {
48    fn consistency() -> ClusterConsistency {
49        ClusterConsistency::EventuallyConsistent
50    }
51}
52
53/// A multi-node location representing a group of identical processes.
54///
55/// Each member of the cluster runs the same dataflow program and is assigned a
56/// unique [`MemberId`] that can be used to address it. The number of members
57/// is determined at deployment time rather than at compile time.
58///
59/// The `ClusterTag` type parameter is a phantom tag used to distinguish between
60/// different clusters in the type system, preventing accidental mixing of
61/// member IDs across clusters.
62pub struct Cluster<'a, ClusterTag, Con: Consistency = NoConsistency> {
63    pub(crate) key: LocationKey,
64    pub(crate) flow_state: FlowState,
65    pub(crate) _phantom: Invariant<'a, (ClusterTag, Con)>,
66}
67
68impl<C, Con: Consistency> Debug for Cluster<'_, C, Con> {
69    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
70        write!(f, "Cluster({})", self.key)
71    }
72}
73
74impl<C, Con: Consistency> Eq for Cluster<'_, C, Con> {}
75impl<C, Con: Consistency> PartialEq for Cluster<'_, C, Con> {
76    fn eq(&self, other: &Self) -> bool {
77        self.key == other.key && FlowState::ptr_eq(&self.flow_state, &other.flow_state)
78    }
79}
80
81impl<C, Con: Consistency> Clone for Cluster<'_, C, Con> {
82    fn clone(&self) -> Self {
83        Cluster {
84            key: self.key,
85            flow_state: self.flow_state.clone(),
86            _phantom: PhantomData,
87        }
88    }
89}
90
91impl<'a, C, Con: Consistency> super::dynamic::DynLocation for Cluster<'a, C, Con> {
92    fn dyn_id(&self) -> LocationId {
93        LocationId::Cluster(self.key, Con::consistency())
94    }
95
96    fn flow_state(&self) -> &FlowState {
97        &self.flow_state
98    }
99
100    fn is_top_level() -> bool {
101        true
102    }
103
104    fn multiversioned(&self) -> bool {
105        false // TODO(shadaj): enable multiversioning support for clusters
106    }
107}
108
109impl<'a, C, Con: Consistency> Location<'a> for Cluster<'a, C, Con> {
110    type Root = Cluster<'a, C, Con>;
111
112    type NoConsistency = Cluster<'a, C, NoConsistency>;
113
114    fn consistency() -> Option<ClusterConsistency> {
115        Some(Con::consistency())
116    }
117
118    fn root(&self) -> Self::Root {
119        self.clone()
120    }
121
122    fn drop_consistency(&self) -> Self::NoConsistency {
123        Cluster {
124            key: self.key,
125            flow_state: self.flow_state.clone(),
126            _phantom: PhantomData,
127        }
128    }
129
130    fn make_from_nondet(l2: Self::NoConsistency) -> Self {
131        Cluster {
132            key: l2.key,
133            flow_state: l2.flow_state,
134            _phantom: PhantomData,
135        }
136    }
137}
138
139#[cfg(feature = "sim")]
140impl<'a, C> Cluster<'a, C> {
141    /// Sets up a simulated input port on this cluster for testing.
142    ///
143    /// Returns a `SimClusterSender` that sends `(member_id, T)` messages targeting
144    /// specific cluster members, and a `Stream<T>` received by each member.
145    #[expect(clippy::type_complexity, reason = "stream markers")]
146    pub fn sim_input<T>(
147        &self,
148    ) -> (
149        crate::sim::SimClusterSender<
150            T,
151            crate::live_collections::stream::TotalOrder,
152            crate::live_collections::stream::ExactlyOnce,
153        >,
154        crate::live_collections::Stream<
155            T,
156            Self,
157            crate::live_collections::boundedness::Unbounded,
158            crate::live_collections::stream::TotalOrder,
159            crate::live_collections::stream::ExactlyOnce,
160        >,
161    )
162    where
163        T: serde::Serialize + serde::de::DeserializeOwned,
164    {
165        use crate::location::Location;
166
167        let external_location: crate::location::External<'a, ()> = crate::location::External {
168            key: LocationKey::FIRST,
169            flow_state: self.flow_state.clone(),
170            _phantom: PhantomData,
171        };
172
173        let (external, stream) = self.source_external_bincode(&external_location);
174
175        (
176            crate::sim::SimClusterSender(external.port_id, PhantomData),
177            stream,
178        )
179    }
180}
181
182/// A free variable that resolves to the list of member IDs in a cluster at runtime.
183///
184/// When spliced into a quoted snippet, this provides access to the set of
185/// [`TaglessMemberId`]s that belong to the cluster.
186pub struct ClusterIds<'a> {
187    /// The location key identifying which cluster this refers to.
188    pub key: LocationKey,
189    /// Phantom data binding the lifetime.
190    pub _phantom: PhantomData<&'a ()>,
191}
192
193impl<'a> Clone for ClusterIds<'a> {
194    fn clone(&self) -> Self {
195        Self {
196            key: self.key,
197            _phantom: Default::default(),
198        }
199    }
200}
201
202impl<'a, Ctx> FreeVariableWithContextWithProps<Ctx, ()> for ClusterIds<'a> {
203    type O = &'a [TaglessMemberId];
204
205    fn to_tokens(self, _ctx: &Ctx) -> (QuoteTokens, ())
206    where
207        Self: Sized,
208    {
209        let ident = syn::Ident::new(
210            &format!("__hydro_lang_cluster_ids_{}", self.key),
211            Span::call_site(),
212        );
213
214        (
215            QuoteTokens {
216                prelude: None,
217                expr: Some(quote! { #ident }),
218            },
219            (),
220        )
221    }
222}
223
224impl<'a, Ctx> QuotedWithContextWithProps<'a, &'a [TaglessMemberId], Ctx, ()> for ClusterIds<'a> {}
225
226/// Marker trait implemented by [`Cluster`] locations, providing access to the cluster tag type.
227pub trait IsCluster {
228    /// The phantom tag type that distinguishes this cluster from others.
229    type Tag;
230}
231
232impl<C> IsCluster for Cluster<'_, C> {
233    type Tag = C;
234}
235
236/// A free variable representing the cluster's own ID. When spliced in
237/// a quoted snippet that will run on a cluster, this turns into a [`MemberId`].
238pub static CLUSTER_SELF_ID: ClusterSelfId = ClusterSelfId { _private: &() };
239
240/// The concrete type behind [`CLUSTER_SELF_ID`].
241///
242/// This is a compile-time variable that, when spliced into a quoted snippet running
243/// on a [`Cluster`], resolves to the [`MemberId`] of the current cluster member.
244#[derive(Clone, Copy)]
245pub struct ClusterSelfId<'a> {
246    _private: &'a (),
247}
248
249impl<'a, L> FreeVariableWithContextWithProps<L, ()> for ClusterSelfId<'a>
250where
251    L: Location<'a>,
252    <L as Location<'a>>::Root: IsCluster,
253{
254    type O = MemberId<<<L as Location<'a>>::Root as IsCluster>::Tag>;
255
256    fn to_tokens(self, ctx: &L) -> (QuoteTokens, ())
257    where
258        Self: Sized,
259    {
260        let LocationId::Cluster(cluster_id, _) = ctx.root().id() else {
261            unreachable!()
262        };
263
264        let ident = syn::Ident::new(
265            &format!("__hydro_lang_cluster_self_id_{}", cluster_id),
266            Span::call_site(),
267        );
268        let root = get_this_crate();
269        let c_type: syn::Type = quote_type::<<<L as Location<'a>>::Root as IsCluster>::Tag>();
270
271        (
272            QuoteTokens {
273                prelude: None,
274                expr: Some(
275                    quote! { #root::__staged::location::MemberId::<#c_type>::from_tagless((#ident).clone()) },
276                ),
277            },
278            (),
279        )
280    }
281}
282
283impl<'a, L>
284    QuotedWithContextWithProps<'a, MemberId<<<L as Location<'a>>::Root as IsCluster>::Tag>, L, ()>
285    for ClusterSelfId<'a>
286where
287    L: Location<'a>,
288    <L as Location<'a>>::Root: IsCluster,
289{
290}
291
292#[cfg(test)]
293mod tests {
294    #[cfg(feature = "sim")]
295    use stageleft::q;
296
297    #[cfg(feature = "sim")]
298    use super::CLUSTER_SELF_ID;
299    #[cfg(feature = "sim")]
300    use crate::location::{Location, MemberId, MembershipEvent};
301    #[cfg(feature = "sim")]
302    use crate::networking::TCP;
303    #[cfg(feature = "sim")]
304    use crate::nondet::nondet;
305    #[cfg(feature = "sim")]
306    use crate::prelude::FlowBuilder;
307
308    #[cfg(feature = "sim")]
309    #[test]
310    fn sim_cluster_self_id() {
311        let mut flow = FlowBuilder::new();
312        let cluster1 = flow.cluster::<()>();
313        let cluster2 = flow.cluster::<()>();
314
315        let node = flow.process::<()>();
316
317        let out_recv = cluster1
318            .source_iter(q!(vec![CLUSTER_SELF_ID]))
319            .send(&node, TCP.fail_stop().bincode())
320            .values()
321            .merge_unordered(
322                cluster2
323                    .source_iter(q!(vec![CLUSTER_SELF_ID]))
324                    .send(&node, TCP.fail_stop().bincode())
325                    .values(),
326            )
327            .sim_output();
328
329        flow.sim()
330            .with_cluster_size(&cluster1, 3)
331            .with_cluster_size(&cluster2, 4)
332            .exhaustive(async || {
333                out_recv
334                    .assert_yields_only_unordered([0, 1, 2, 0, 1, 2, 3].map(MemberId::from_raw_id))
335                    .await
336            });
337    }
338
339    #[cfg(feature = "sim")]
340    #[test]
341    fn sim_cluster_with_tick() {
342        use std::collections::HashMap;
343
344        let mut flow = FlowBuilder::new();
345        let cluster = flow.cluster::<()>();
346        let node = flow.process::<()>();
347
348        let out_recv = cluster
349            .source_iter(q!(vec![1, 2, 3]))
350            .batch(&cluster.tick(), nondet!(/** test */))
351            .count()
352            .all_ticks()
353            .send(&node, TCP.fail_stop().bincode())
354            .entries()
355            .map(q!(|(id, v)| (id, v)))
356            .sim_output();
357
358        let count = flow
359            .sim()
360            .with_cluster_size(&cluster, 2)
361            .exhaustive(async || {
362                let grouped = out_recv.collect_sorted::<Vec<_>>().await.into_iter().fold(
363                    HashMap::new(),
364                    |mut acc: HashMap<MemberId<()>, usize>, (id, v)| {
365                        *acc.entry(id).or_default() += v;
366                        acc
367                    },
368                );
369
370                assert!(grouped.len() == 2);
371                for (_id, v) in grouped {
372                    assert!(v == 3);
373                }
374            });
375
376        assert_eq!(count, 106);
377        // not a square because we simulate all interleavings of ticks across 2 cluster members
378        // eventually, we should be able to identify that the members are independent (because
379        // there are no dataflow cycles) and avoid simulating redundant interleavings
380    }
381
382    #[cfg(feature = "sim")]
383    #[test]
384    fn sim_cluster_membership() {
385        let mut flow = FlowBuilder::new();
386        let cluster = flow.cluster::<()>();
387        let node = flow.process::<()>();
388
389        let out_recv = node
390            .source_cluster_membership_stream(&cluster, nondet!(/** test */))
391            .entries()
392            .map(q!(|(id, v)| (id, v)))
393            .sim_output();
394
395        flow.sim()
396            .with_cluster_size(&cluster, 2)
397            .exhaustive(async || {
398                out_recv
399                    .assert_yields_only_unordered(vec![
400                        (MemberId::from_raw_id(0), MembershipEvent::Joined),
401                        (MemberId::from_raw_id(1), MembershipEvent::Joined),
402                    ])
403                    .await;
404            });
405    }
406}