Skip to main content

fv_streams_runtime/
placement.rs

1//! DETERMINISTIC PLACEMENT (D8 / D9): every worker computes the SAME task→worker map from the
2//! topology and the sorted worker set, so no worker is told its placement and there is no control
3//! plane that owns it (D9 — "zero control plane; the leader is a worker"). Two properties make that
4//! sound:
5//!
6//! - **Deterministic and contiguous.** A stage's parallel instances are handed out in contiguous
7//!   blocks ([`owner_of`], instance `i` of `n` → worker `⌊i·w/n⌋`), mirroring [`crate::dataflow::
8//!   vnode_ranges`]. A worker therefore owns a contiguous span of a stage's vnode ranges — fewer
9//!   cross-worker edges — and the same `(topology, sorted members)` reproduces the same map on every
10//!   worker and across a restart.
11//! - **Part of the fingerprint.** The member set folds into the checkpoint fingerprint
12//!   ([`Members::fingerprint_clause`]), so a member-set change is a topology change that refuses to
13//!   restore over the old checkpoint unless `STREAM_RESET_STATE=1` (D8 — restart-based rescale
14//!   first; 4c does the authorised re-assignment). A **solo** run (no `--join`) contributes an empty
15//!   clause, so its fingerprint — and thus every existing checkpoint — is byte-identical to the
16//!   pre-Phase-4 runtime. No regression.
17//!
18//! The single-process runtime is unchanged: [`Members::solo`] places every task on the one worker,
19//! and the exchange wiring (the remote edge wiring) then has no remote edges at all.
20use std::collections::HashMap;
21
22/// A worker's index within the sorted member set.
23pub type WorkerId = u32;
24
25/// The sorted, de-duplicated set of a run's workers, plus this process's own index. Sorting makes
26/// the assignment independent of the order workers happened to join in — the invariant [`owner_of`]
27/// relies on.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct Members {
30    /// Canonical worker addresses, sorted and de-duplicated. A solo run holds one empty entry.
31    workers: Vec<String>,
32    /// This process's index into `workers`.
33    me: WorkerId,
34}
35
36impl Members {
37    /// A single-worker run — the default when there is no `--join`. Byte-for-byte the pre-Phase-4
38    /// behaviour: one worker owns every task, and the fingerprint clause is empty.
39    pub fn solo() -> Self {
40        Members {
41            workers: vec![String::new()],
42            me: 0,
43        }
44    }
45
46    /// From a membership list (in any order) and this process's own address. The list is sorted and
47    /// de-duplicated; `me` is this address's index in the sorted set. Errors if `my_addr` is not in
48    /// the set (a worker must be a member of the run it joins).
49    pub fn new(workers: Vec<String>, my_addr: &str) -> Result<Self, String> {
50        let mut workers: Vec<String> = workers;
51        workers.sort();
52        workers.dedup();
53        if workers.is_empty() {
54            return Err("empty membership set".into());
55        }
56        let me = workers
57            .iter()
58            .position(|w| w == my_addr)
59            .ok_or_else(|| format!("this worker ({my_addr}) is not in the membership set"))?
60            as WorkerId;
61        Ok(Members { workers, me })
62    }
63
64    /// The number of workers in the run.
65    pub fn len(&self) -> usize {
66        self.workers.len()
67    }
68    pub fn is_empty(&self) -> bool {
69        self.len() == 0
70    }
71
72    /// This process's worker id.
73    pub fn me(&self) -> WorkerId {
74        self.me
75    }
76
77    /// A run with exactly one worker: every edge is local and there is nothing to coordinate across
78    /// processes.
79    pub fn is_solo(&self) -> bool {
80        self.workers.len() == 1
81    }
82
83    /// The address of worker `w` (for opening exchange links to peers).
84    pub fn addr(&self, w: WorkerId) -> Option<&str> {
85        self.workers.get(w as usize).map(|s| s.as_str())
86    }
87
88    /// Every worker's id except this one — the peers this worker opens links to.
89    pub fn peers(&self) -> impl Iterator<Item = WorkerId> + '_ {
90        (0..self.workers.len() as WorkerId).filter(move |&w| w != self.me)
91    }
92
93    /// The fingerprint clause for this member set. **Empty for a solo run**, so a single-worker
94    /// fingerprint — and every checkpoint written before Phase 4 — is unchanged. For a cluster, the
95    /// worker *count*: placement ([`owner_of`]) depends only on the number of workers, so a change to
96    /// it changes every task's owner and must refuse to restore over the old checkpoint (the D8
97    /// fence), while a restart of the same-sized cluster — on whatever addresses/ports — restores
98    /// cleanly.
99    pub fn fingerprint_clause(&self) -> String {
100        if self.is_solo() {
101            String::new()
102        } else {
103            format!("workers={};", self.workers.len())
104        }
105    }
106}
107
108/// The owner of parallel instance `i` of an `n`-way stage across `w` workers. Contiguous blocks,
109/// mirroring [`crate::dataflow::vnode_ranges`] (instance `i` → worker `⌊i·w/n⌋`), so a worker owns a
110/// contiguous vnode span and adjacent instances tend to share a worker (fewer remote edges). Stable
111/// under restart with the same `w`. `w` and `n` are clamped to at least 1; `i ≥ n` is treated as the
112/// last instance (defensive — callers pass `i < n`).
113pub fn owner_of(i: usize, n: usize, w: usize) -> WorkerId {
114    let w = w.max(1);
115    let n = n.max(1);
116    ((i.min(n - 1) * w) / n) as WorkerId
117}
118
119/// Place a whole graph: task id → worker, deriving each task's stage from the builder's `roles` and
120/// `stage_of` (both indexed by task id, in creation = id order). A stage's parallel instances are
121/// the tasks that share a `(role, stage)` key, in id order; each is spread by [`owner_of`]. Because
122/// `owner_of` depends only on `(instance, n, workers)`, a 1:1 forward chain (upstream and downstream
123/// stages of equal width, paired by instance) lands each pair on the same worker — a forward edge
124/// stays in-process — while a keyBy/shuffle stage of a different width spreads across workers. A
125/// solo run (`members.len() == 1`) places everything on worker 0, so `start` and a solo
126/// `start_worker` are identical.
127pub fn assign(roles: &[&str], stage_of: &[usize], members: &Members) -> Vec<WorkerId> {
128    assert_eq!(
129        roles.len(),
130        stage_of.len(),
131        "roles and stage_of are both indexed by task id"
132    );
133    let w = members.len();
134    let mut width: HashMap<(usize, &str), usize> = HashMap::new();
135    for (r, s) in roles.iter().zip(stage_of) {
136        *width.entry((*s, *r)).or_default() += 1;
137    }
138    let mut pos: HashMap<(usize, &str), usize> = HashMap::new();
139    roles
140        .iter()
141        .zip(stage_of)
142        .map(|(r, s)| {
143            let key = (*s, *r);
144            let i = pos.entry(key).or_insert(0);
145            let owner = owner_of(*i, width[&key], w);
146            *i += 1;
147            owner
148        })
149        .collect()
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn solo_places_everything_on_the_one_worker_and_adds_no_fingerprint() {
158        let m = Members::solo();
159        assert!(m.is_solo());
160        assert_eq!(m.len(), 1);
161        assert_eq!(m.me(), 0);
162        // every instance of every stage is local (worker 0).
163        for n in 1..=8 {
164            for i in 0..n {
165                assert_eq!(owner_of(i, n, m.len()), m.me(), "solo owns instance {i}/{n}");
166            }
167        }
168        assert_eq!(m.peers().count(), 0, "a solo run has no peers");
169        assert_eq!(
170            m.fingerprint_clause(),
171            "",
172            "solo contributes nothing to the fingerprint"
173        );
174    }
175
176    #[test]
177    fn owner_is_contiguous_and_covers_every_worker() {
178        // 8 instances across 3 workers → contiguous blocks [0,0,0][1,1,1][2,2] (⌊i·3/8⌋).
179        let got: Vec<WorkerId> = (0..8).map(|i| owner_of(i, 8, 3)).collect();
180        assert_eq!(got, vec![0, 0, 0, 1, 1, 1, 2, 2]);
181        // blocks are contiguous (never 0,1,0) and every worker owns at least one when n ≥ w.
182        let mut seen = [false; 3];
183        for w in &got {
184            seen[*w as usize] = true;
185        }
186        assert!(seen.iter().all(|&s| s), "every worker owns a block");
187        // monotonic non-decreasing → contiguous.
188        assert!(got.windows(2).all(|p| p[0] <= p[1]), "assignment is contiguous");
189    }
190
191    #[test]
192    fn owner_matches_vnode_ranges_so_a_worker_owns_a_contiguous_vnode_span() {
193        // The parallel instances that vnode_ranges splits a stage into are the same instances
194        // owner_of assigns; a worker's instances must form a contiguous vnode span (no holes).
195        let (vnodes, n, w) = (64u32, 8usize, 3usize);
196        let ranges = crate::dataflow::vnode_ranges(vnodes, n as u32);
197        for me in 0..w as WorkerId {
198            let mine: Vec<usize> = (0..n).filter(|&i| owner_of(i, n, w) == me).collect();
199            if mine.is_empty() {
200                continue;
201            }
202            // contiguous instance ids → contiguous, gap-free vnode span.
203            assert!(
204                mine.windows(2).all(|p| p[1] == p[0] + 1),
205                "worker {me}'s instances are contiguous"
206            );
207            let span_start = ranges[mine[0]].start;
208            let span_end = ranges[*mine.last().unwrap()].end;
209            let covered: u32 = mine.iter().map(|&i| ranges[i].end - ranges[i].start).sum();
210            assert_eq!(span_end - span_start, covered, "worker {me}'s vnode span has no holes");
211        }
212    }
213
214    #[test]
215    fn new_sorts_and_dedups_so_assignment_is_join_order_independent() {
216        // Two workers that joined in different orders must agree on who is who.
217        let a = Members::new(vec!["10.0.0.2:7000".into(), "10.0.0.1:7000".into()], "10.0.0.1:7000").unwrap();
218        let b = Members::new(vec!["10.0.0.1:7000".into(), "10.0.0.2:7000".into()], "10.0.0.2:7000").unwrap();
219        assert_eq!(a.len(), 2);
220        assert_eq!(
221            a.me(),
222            0,
223            "the lexicographically-smaller address is worker 0 on every node"
224        );
225        assert_eq!(b.me(), 1);
226        assert_eq!(
227            a.fingerprint_clause(),
228            b.fingerprint_clause(),
229            "both derive the same fingerprint"
230        );
231        // a duplicate address collapses.
232        let dup = Members::new(vec!["x".into(), "x".into(), "y".into()], "y").unwrap();
233        assert_eq!(dup.len(), 2);
234    }
235
236    #[test]
237    fn new_rejects_a_worker_that_is_not_a_member() {
238        assert!(Members::new(vec!["a".into(), "b".into()], "c").is_err());
239        assert!(Members::new(vec![], "a").is_err());
240    }
241
242    #[test]
243    fn assign_co_locates_forward_chains_and_spreads_shuffle_stages() {
244        // A pipeline: 4 sources -> 4 forward operators -> a 2-way keyBy stage -> 1 sink.
245        let roles = [
246            "sources",
247            "sources",
248            "sources",
249            "sources", // stage 0
250            "operators",
251            "operators",
252            "operators",
253            "operators", // stage 1 (forward 1:1)
254            "operators",
255            "operators", // stage 2 (keyBy, width 2)
256            "sinks",     // final
257        ];
258        let stage_of = [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2];
259        let two = Members::new(vec!["a".into(), "b".into()], "a").unwrap();
260        let of = assign(&roles, &stage_of, &two);
261        // sources spread [0,0,1,1]; the forward operators land on the SAME workers (co-located).
262        assert_eq!(&of[0..4], &[0, 0, 1, 1], "sources spread across workers");
263        assert_eq!(&of[4..8], &[0, 0, 1, 1], "the forward chain co-locates with its source");
264        assert_eq!(&of[8..10], &[0, 1], "the keyBy stage spreads over both workers");
265        assert_eq!(of[10], 0, "the single sink lands on one worker");
266
267        // A solo run places everything on worker 0 — identical to `start`.
268        let solo = assign(&roles, &stage_of, &Members::solo());
269        assert!(solo.iter().all(|&w| w == 0), "a solo run is all local");
270    }
271
272    #[test]
273    fn a_member_set_change_changes_the_fingerprint_but_solo_never_does() {
274        let two = Members::new(vec!["a".into(), "b".into()], "a").unwrap();
275        let three = Members::new(vec!["a".into(), "b".into(), "c".into()], "a").unwrap();
276        assert_ne!(
277            two.fingerprint_clause(),
278            three.fingerprint_clause(),
279            "adding a worker is a topology change (the D8 fence)"
280        );
281        // the SAME set from a different member is the same clause (co-derivable).
282        let two_from_b = Members::new(vec!["b".into(), "a".into()], "b").unwrap();
283        assert_eq!(two.fingerprint_clause(), two_from_b.fingerprint_clause());
284        assert_eq!(
285            Members::solo().fingerprint_clause(),
286            "",
287            "a solo run is fingerprint-stable forever"
288        );
289    }
290}