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::dynamic::ClusterConsistency;
24use crate::location::member_id::TaglessMemberId;
25use crate::location::{LocationKey, TopLevel};
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::EventualConsistency
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)
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    fn cluster_consistency() -> Option<ClusterConsistency> {
109        Some(Con::consistency())
110    }
111}
112
113impl<'a, C, Con: Consistency> Location<'a> for Cluster<'a, C, Con> {
114    type Root = Cluster<'a, C, Con>;
115
116    type SimHookScope = crate::sim_hooks::OnCluster<C>;
117
118    type DropConsistency = Cluster<'a, C, NoConsistency>;
119
120    fn consistency() -> Option<ClusterConsistency> {
121        Some(Con::consistency())
122    }
123
124    fn root(&self) -> Self::Root {
125        self.clone()
126    }
127
128    fn drop_consistency(&self) -> Self::DropConsistency {
129        Cluster {
130            key: self.key,
131            flow_state: self.flow_state.clone(),
132            _phantom: PhantomData,
133        }
134    }
135
136    fn from_drop_consistency(l2: Self::DropConsistency) -> Self {
137        Cluster {
138            key: l2.key,
139            flow_state: l2.flow_state,
140            _phantom: PhantomData,
141        }
142    }
143}
144
145impl<'a, C, Con: Consistency> TopLevel<'a> for Cluster<'a, C, Con> {}
146
147#[cfg(feature = "sim")]
148impl<'a, C> Cluster<'a, C> {
149    /// Sets up a MessagePack-encoded simulated input port on this cluster for testing.
150    ///
151    /// Returns a `SimClusterSender` that sends `(member_id, T)` messages targeting
152    /// specific cluster members, and a `Stream<T>` received by each member. Use
153    /// [`Cluster::sim_input_with`] to select another codec.
154    ///
155    /// This method is generic over the [`Ordering`](crate::live_collections::stream::Ordering)
156    /// and [`Retries`](crate::live_collections::stream::Retries) guarantees of the produced
157    /// stream, mirroring [`Location::sim_input`]. For
158    /// unordered inputs (e.g. anything downstream of a `NoOrder` network channel), create the
159    /// input with `O = NoOrder` and drive it with
160    /// [`SimClusterSender::send_many_unordered`](crate::sim::SimClusterSender::send_many_unordered).
161    pub fn sim_input<
162        T,
163        O: crate::live_collections::stream::Ordering,
164        R: crate::live_collections::stream::Retries,
165    >(
166        &self,
167    ) -> (
168        crate::sim::SimClusterSender<T, O, R>,
169        crate::live_collections::Stream<
170            T,
171            Self,
172            crate::live_collections::boundedness::Unbounded,
173            O,
174            R,
175        >,
176    )
177    where
178        T: serde::Serialize + serde::de::DeserializeOwned,
179    {
180        self.sim_input_with::<crate::sim::codec::RmpCodec, T, O, R>()
181    }
182
183    /// Sets up a simulated input port on this cluster using the codec `Codec`.
184    ///
185    /// Returns a `SimClusterSender` that sends `(member_id, T)` messages targeting
186    /// specific cluster members, and a `Stream<T>` received by each member. The codec is a
187    /// type parameter; the message, ordering and retries types are usually inferred:
188    /// `cluster.sim_input_with::<MyCodec, _, _, _>()`. Custom codecs implement
189    /// [`SimCodec`](crate::sim::codec::SimCodec), which documents where they must be
190    /// defined. See [`Cluster::sim_input`] for the ordering and retries guarantees.
191    pub fn sim_input_with<
192        Codec: crate::sim::codec::SimCodec<T>,
193        T,
194        O: crate::live_collections::stream::Ordering,
195        R: crate::live_collections::stream::Retries,
196    >(
197        &self,
198    ) -> (
199        crate::sim::SimClusterSender<T, O, R>,
200        crate::live_collections::Stream<
201            T,
202            Self,
203            crate::live_collections::boundedness::Unbounded,
204            O,
205            R,
206        >,
207    ) {
208        let external_location: crate::location::External<'a, ()> = crate::location::External {
209            key: LocationKey::FIRST,
210            flow_state: self.flow_state.clone(),
211            _phantom: PhantomData,
212        };
213
214        let (external_port_id, stream) = super::register_serialized_external_input(
215            self,
216            &external_location,
217            crate::sim::codec::staged_deserialize::<T, Codec>(),
218        );
219
220        (
221            crate::sim::SimClusterSender(external_port_id, PhantomData, Codec::encode),
222            stream.weaken_ordering().weaken_retries(),
223        )
224    }
225}
226
227/// A free variable that resolves to the list of member IDs in a cluster at runtime.
228///
229/// When spliced into a quoted snippet, this provides access to the set of
230/// [`TaglessMemberId`]s that belong to the cluster.
231pub struct ClusterIds<'a> {
232    /// The location key identifying which cluster this refers to.
233    pub key: LocationKey,
234    /// Phantom data binding the lifetime.
235    pub _phantom: PhantomData<&'a ()>,
236}
237
238impl<'a> Clone for ClusterIds<'a> {
239    fn clone(&self) -> Self {
240        Self {
241            key: self.key,
242            _phantom: Default::default(),
243        }
244    }
245}
246
247impl<'a, Ctx> FreeVariableWithContextWithProps<Ctx, ()> for ClusterIds<'a> {
248    type O = &'a [TaglessMemberId];
249
250    fn to_tokens(self, _ctx: &Ctx) -> (QuoteTokens, ())
251    where
252        Self: Sized,
253    {
254        let ident = syn::Ident::new(
255            &format!("__hydro_lang_cluster_ids_{}", self.key),
256            Span::call_site(),
257        );
258
259        (
260            QuoteTokens {
261                prelude: None,
262                expr: Some(quote! { #ident }),
263            },
264            (),
265        )
266    }
267}
268
269impl<'a, Ctx> QuotedWithContextWithProps<'a, &'a [TaglessMemberId], Ctx, ()> for ClusterIds<'a> {}
270
271/// Marker trait implemented by [`Cluster`] locations, providing access to the cluster tag type.
272pub trait IsCluster {
273    /// The phantom tag type that distinguishes this cluster from others.
274    type Tag;
275}
276
277impl<C> IsCluster for Cluster<'_, C> {
278    type Tag = C;
279}
280
281/// A free variable representing the cluster's own ID. When spliced in
282/// a quoted snippet that will run on a cluster, this turns into a [`MemberId`].
283pub static CLUSTER_SELF_ID: ClusterSelfId<'static> = ClusterSelfId { _private: &() };
284
285/// The concrete type behind [`CLUSTER_SELF_ID`].
286///
287/// This is a compile-time variable that, when spliced into a quoted snippet running
288/// on a [`Cluster`], resolves to the [`MemberId`] of the current cluster member.
289#[derive(Clone, Copy)]
290pub struct ClusterSelfId<'a> {
291    _private: &'a (),
292}
293
294impl<'a, Ctx> FreeVariableWithContextWithProps<Ctx, ()> for ClusterSelfId<'a>
295where
296    Ctx: crate::live_collections::ContextWithLocation<'a>,
297    <Ctx::Location as Location<'a>>::Root: IsCluster,
298{
299    type O = MemberId<<<Ctx::Location as Location<'a>>::Root as IsCluster>::Tag>;
300
301    fn to_tokens(self, ctx: &Ctx) -> (QuoteTokens, ())
302    where
303        Self: Sized,
304    {
305        let LocationId::Cluster(cluster_id) = ctx.context_location().root().id() else {
306            unreachable!()
307        };
308        crate::location::dynamic::DynLocation::flow_state(ctx.context_location())
309            .borrow_mut()
310            .clusters_reading_self_id
311            .insert(cluster_id);
312
313        let ident = syn::Ident::new(
314            &format!("__hydro_lang_cluster_self_id_{}", cluster_id),
315            Span::call_site(),
316        );
317        let root = get_this_crate();
318        let c_type: syn::Type =
319            quote_type::<<<Ctx::Location as Location<'a>>::Root as IsCluster>::Tag>();
320
321        (
322            QuoteTokens {
323                prelude: None,
324                expr: Some(
325                    quote! { #root::__staged::location::MemberId::<#c_type>::from_tagless((#ident).clone()) },
326                ),
327            },
328            (),
329        )
330    }
331}
332
333impl<'a, Ctx>
334    QuotedWithContextWithProps<
335        'a,
336        MemberId<<<Ctx::Location as Location<'a>>::Root as IsCluster>::Tag>,
337        Ctx,
338        (),
339    > for ClusterSelfId<'a>
340where
341    Ctx: crate::live_collections::ContextWithLocation<'a>,
342    <Ctx::Location as Location<'a>>::Root: IsCluster,
343{
344}
345
346#[cfg(test)]
347mod tests {
348    #[cfg(feature = "sim")]
349    use stageleft::q;
350
351    #[cfg(feature = "sim")]
352    use super::CLUSTER_SELF_ID;
353    #[cfg(feature = "sim")]
354    use crate::location::{Location, MemberId, MembershipEvent};
355    #[cfg(feature = "sim")]
356    use crate::networking::TCP;
357    #[cfg(feature = "sim")]
358    use crate::nondet::nondet;
359    #[cfg(feature = "sim")]
360    use crate::prelude::FlowBuilder;
361
362    #[cfg(feature = "sim")]
363    #[test]
364    fn sim_cluster_self_id() {
365        let mut flow = FlowBuilder::new();
366        let cluster1 = flow.cluster::<()>();
367        let cluster2 = flow.cluster::<()>();
368
369        let node = flow.process::<()>();
370
371        let out_recv = cluster1
372            .source_iter(q!(vec![CLUSTER_SELF_ID]))
373            .send(&node, TCP.fail_stop().bincode())
374            .values()
375            .merge_unordered(
376                cluster2
377                    .source_iter(q!(vec![CLUSTER_SELF_ID]))
378                    .send(&node, TCP.fail_stop().bincode())
379                    .values(),
380            )
381            .sim_output();
382
383        flow.sim()
384            .with_cluster_size(&cluster1, 3)
385            .with_cluster_size(&cluster2, 4)
386            .exhaustive(async || {
387                out_recv
388                    .assert_yields_only_unordered([0, 1, 2, 0, 1, 2, 3].map(MemberId::from_raw_id))
389                    .await
390            });
391    }
392
393    #[cfg(feature = "sim")]
394    #[test]
395    fn sim_cluster_with_tick() {
396        use std::collections::HashMap;
397
398        let mut flow = FlowBuilder::new();
399        let cluster = flow.cluster::<()>();
400        let node = flow.process::<()>();
401
402        let out_recv = cluster
403            .source_iter(q!(vec![1, 2, 3]))
404            .batch(&cluster.tick(), nondet!(/** test */))
405            .count()
406            .all_ticks()
407            .send(&node, TCP.fail_stop().bincode())
408            .entries()
409            .map(q!(|(id, v)| (id, v)))
410            .sim_output();
411
412        let count = flow
413            .sim()
414            .with_cluster_size(&cluster, 2)
415            .exhaustive(async || {
416                let grouped = out_recv.collect_sorted::<Vec<_>>().await.into_iter().fold(
417                    HashMap::new(),
418                    |mut acc: HashMap<MemberId<()>, usize>, (id, v)| {
419                        *acc.entry(id).or_default() += v;
420                        acc
421                    },
422                );
423
424                assert_eq!(grouped.len(), 2);
425                for (_id, v) in grouped {
426                    assert_eq!(v, 3);
427                }
428            });
429
430        assert_eq!(count, 106);
431        // not a square because we simulate all interleavings of ticks across 2 cluster members
432        // eventually, we should be able to identify that the members are independent (because
433        // there are no dataflow cycles) and avoid simulating redundant interleavings
434    }
435
436    #[cfg(feature = "sim")]
437    #[test]
438    fn sim_cluster_membership() {
439        let mut flow = FlowBuilder::new();
440        let cluster = flow.cluster::<()>();
441        let node = flow.process::<()>();
442
443        let out_recv = node
444            .source_cluster_membership_stream(&cluster, nondet!(/** test */))
445            .entries()
446            .map(q!(|(id, v)| (id, v)))
447            .sim_output();
448
449        flow.sim()
450            .with_cluster_size(&cluster, 2)
451            .exhaustive(async || {
452                out_recv
453                    .assert_yields_only_unordered(vec![
454                        (MemberId::from_raw_id(0), MembershipEvent::Joined),
455                        (MemberId::from_raw_id(1), MembershipEvent::Joined),
456                    ])
457                    .await;
458            });
459    }
460}