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}