Skip to main content

eredu_core/
consensus.rs

1//! Backend-neutral distributed scheduler consensus.
2//!
3//! Core defines the wire records and validates rank agreement. A backend
4//! adapter supplies only a topology-scoped all-gather of portable words.
5
6use crate::scheduler::{CancellationCause, RequestId, WorkId};
7
8/// Topology-scoped transport for scheduler metadata.
9///
10/// Implementations must return rank-major concatenation of one equally sized
11/// word frame from every participant. The scheduler never sends tensors,
12/// caches, streams, or executable objects through this interface.
13pub trait ConsensusTransport {
14    /// Transport error.
15    type Error: std::error::Error;
16
17    /// Number of ranks in the consensus topology.
18    fn participant_count(&self) -> usize;
19
20    /// Gathers an equally sized word frame from every rank in rank order.
21    fn all_gather_words(&self, local: &[u32]) -> Result<Vec<u32>, Self::Error>;
22}
23
24/// One planned transition and its stable semantic descriptor.
25#[derive(Debug, Clone, Copy, Eq, PartialEq)]
26pub struct ScheduledWork<'a> {
27    /// Scheduler transition identity.
28    pub id: WorkId,
29    /// Program-specific stable descriptor words.
30    pub descriptor: &'a [u32],
31}
32
33/// Exact local completion observation before rank consensus.
34#[derive(Debug, Clone, Copy, Eq, PartialEq)]
35pub enum CompletionObservation {
36    /// Local backend work is incomplete.
37    Incomplete,
38    /// Local backend work completed successfully.
39    Complete,
40    /// Exact local completion observation failed.
41    Failed,
42}
43
44impl CompletionObservation {
45    const fn wire(self) -> u32 {
46        match self {
47            Self::Incomplete => 0,
48            Self::Complete => 1,
49            Self::Failed => 2,
50        }
51    }
52}
53
54/// Topology-wide resolution for one submitted transition.
55#[derive(Debug, Clone, Copy, Eq, PartialEq)]
56pub enum CompletionResolution {
57    /// At least one rank is still executing and no rank failed.
58    Incomplete,
59    /// Every rank completed successfully.
60    Complete,
61    /// At least one rank failed while another remains incomplete.
62    FailedPending,
63    /// At least one rank failed and every rank reached an exact terminal state.
64    FailedComplete,
65}
66
67/// Structured consensus validation failure without backend error types.
68#[derive(Debug, Clone, Eq, PartialEq, thiserror::Error)]
69pub enum ConsensusError {
70    /// A consensus topology cannot be empty.
71    #[error("distributed scheduler consensus topology has no participants")]
72    EmptyTopology,
73    /// Portable metadata exceeded its wire representation.
74    #[error("distributed scheduler {0} exceeds u32")]
75    MetadataOverflow(&'static str),
76    /// The backend collective failed.
77    #[error("distributed scheduler consensus failed: {0}")]
78    Transport(String),
79    /// The transport returned a malformed rank-major gather.
80    #[error(
81        "distributed scheduler consensus returned {actual} words; expected {expected} for {participants} ranks"
82    )]
83    MalformedGather {
84        /// Expected gathered word count.
85        expected: usize,
86        /// Actual gathered word count.
87        actual: usize,
88        /// Expected participant count.
89        participants: usize,
90    },
91    /// A schedule or disposition frame differed.
92    #[error("{context} differs at rank {rank}")]
93    Mismatch {
94        /// Operation being validated.
95        context: &'static str,
96        /// First disagreeing rank.
97        rank: usize,
98    },
99    /// Completion protocol, work count, or frame size differed.
100    #[error("distributed completion header differs at rank {rank}")]
101    CompletionHeader {
102        /// First disagreeing rank.
103        rank: usize,
104    },
105    /// Completion work ordering differed.
106    #[error("distributed completion identity differs at rank {rank}")]
107    CompletionIdentity {
108        /// First disagreeing rank.
109        rank: usize,
110    },
111    /// A rank emitted an unknown completion status.
112    #[error("distributed completion status is invalid at rank {rank}")]
113    CompletionStatus {
114        /// First rank with an invalid value.
115        rank: usize,
116    },
117}
118
119/// Validates exact work ordering and descriptors across a topology.
120pub fn validate_schedule<T: ConsensusTransport>(
121    transport: &T,
122    plan: &[ScheduledWork<'_>],
123    drain_cycle: u64,
124    protocol: u64,
125) -> Result<(), ConsensusError> {
126    let mut words = vec![
127        u32::try_from(plan.len())
128            .map_err(|_| ConsensusError::MetadataOverflow("schedule length"))?,
129        drain_cycle as u32,
130        (drain_cycle >> 32) as u32,
131        protocol as u32,
132        (protocol >> 32) as u32,
133    ];
134    for work in plan {
135        push_u64(&mut words, work.id.request().value());
136        push_u64(&mut words, work.id.sequence());
137        words.push(
138            u32::try_from(work.descriptor.len())
139                .map_err(|_| ConsensusError::MetadataOverflow("work descriptor length"))?,
140        );
141        words.extend_from_slice(work.descriptor);
142    }
143    validate_equal_words(transport, &words, "distributed work descriptors")
144}
145
146/// Validates a cancellation or deadline disposition across a topology.
147pub fn validate_disposition<T: ConsensusTransport>(
148    transport: &T,
149    protocol: u64,
150    request: RequestId,
151    cause: CancellationCause,
152) -> Result<(), ConsensusError> {
153    let mut words = vec![protocol as u32, (protocol >> 32) as u32];
154    push_u64(&mut words, request.value());
155    words.push(match cause {
156        CancellationCause::Explicit => 1,
157        CancellationCause::Deadline => 2,
158    });
159    validate_equal_words(transport, &words, "distributed cancellation disposition")
160}
161
162/// Resolves exact local completion observations into topology-wide outcomes.
163pub fn resolve_completions<T: ConsensusTransport>(
164    transport: &T,
165    protocol: u64,
166    local: &[(WorkId, CompletionObservation)],
167) -> Result<Vec<CompletionResolution>, ConsensusError> {
168    let participants = checked_participants(transport)?;
169    if participants == 1 {
170        return Ok(local
171            .iter()
172            .map(|(_, status)| match status {
173                CompletionObservation::Incomplete => CompletionResolution::Incomplete,
174                CompletionObservation::Complete => CompletionResolution::Complete,
175                CompletionObservation::Failed => CompletionResolution::FailedComplete,
176            })
177            .collect());
178    }
179
180    let mut words = vec![
181        protocol as u32,
182        (protocol >> 32) as u32,
183        u32::try_from(local.len())
184            .map_err(|_| ConsensusError::MetadataOverflow("completion work count"))?,
185    ];
186    for (id, status) in local {
187        push_u64(&mut words, id.request().value());
188        push_u64(&mut words, id.sequence());
189        words.push(status.wire());
190    }
191    let gathered = gather_words(transport, &words, participants)?;
192    for rank in 0..participants {
193        let candidate = &gathered[rank * words.len()..(rank + 1) * words.len()];
194        if candidate[..3] != words[..3] {
195            return Err(ConsensusError::CompletionHeader { rank });
196        }
197        for (index, (id, _)) in local.iter().enumerate() {
198            let offset = 3 + index * 5;
199            let expected = [
200                id.request().value() as u32,
201                (id.request().value() >> 32) as u32,
202                id.sequence() as u32,
203                (id.sequence() >> 32) as u32,
204            ];
205            if candidate[offset..offset + 4] != expected {
206                return Err(ConsensusError::CompletionIdentity { rank });
207            }
208            if candidate[offset + 4] > CompletionObservation::Failed.wire() {
209                return Err(ConsensusError::CompletionStatus { rank });
210            }
211        }
212    }
213
214    Ok((0..local.len())
215        .map(|index| {
216            let statuses =
217                (0..participants).map(|rank| gathered[rank * words.len() + 3 + index * 5 + 4]);
218            let statuses = statuses.collect::<Vec<_>>();
219            let failed = statuses.contains(&CompletionObservation::Failed.wire());
220            let incomplete = statuses.contains(&CompletionObservation::Incomplete.wire());
221            match (failed, incomplete) {
222                (true, true) => CompletionResolution::FailedPending,
223                (true, false) => CompletionResolution::FailedComplete,
224                (false, true) => CompletionResolution::Incomplete,
225                (false, false) => CompletionResolution::Complete,
226            }
227        })
228        .collect())
229}
230
231fn validate_equal_words<T: ConsensusTransport>(
232    transport: &T,
233    words: &[u32],
234    context: &'static str,
235) -> Result<(), ConsensusError> {
236    let participants = checked_participants(transport)?;
237    if participants == 1 {
238        return Ok(());
239    }
240    let gathered = gather_words(transport, words, participants)?;
241    for rank in 0..participants {
242        let start = rank * words.len();
243        let end = start + words.len();
244        if gathered.get(start..end) != Some(words) {
245            return Err(ConsensusError::Mismatch { context, rank });
246        }
247    }
248    Ok(())
249}
250
251fn checked_participants<T: ConsensusTransport>(transport: &T) -> Result<usize, ConsensusError> {
252    let participants = transport.participant_count();
253    if participants == 0 {
254        Err(ConsensusError::EmptyTopology)
255    } else {
256        Ok(participants)
257    }
258}
259
260fn gather_words<T: ConsensusTransport>(
261    transport: &T,
262    words: &[u32],
263    participants: usize,
264) -> Result<Vec<u32>, ConsensusError> {
265    let expected = words
266        .len()
267        .checked_mul(participants)
268        .ok_or(ConsensusError::MetadataOverflow("gathered word count"))?;
269    let gathered = transport
270        .all_gather_words(words)
271        .map_err(|error| ConsensusError::Transport(error.to_string()))?;
272    if gathered.len() != expected {
273        return Err(ConsensusError::MalformedGather {
274            expected,
275            actual: gathered.len(),
276            participants,
277        });
278    }
279    Ok(gathered)
280}
281
282fn push_u64(output: &mut Vec<u32>, value: u64) {
283    output.extend_from_slice(&[value as u32, (value >> 32) as u32]);
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use std::{cell::RefCell, convert::Infallible};
290
291    type GatherMutation = dyn FnMut(&mut [u32], usize);
292
293    struct MockTransport {
294        participants: usize,
295        mutate: RefCell<Option<Box<GatherMutation>>>,
296    }
297
298    impl MockTransport {
299        fn agreeing(participants: usize) -> Self {
300            Self {
301                participants,
302                mutate: RefCell::new(None),
303            }
304        }
305
306        fn mutating(participants: usize, mutate: impl FnMut(&mut [u32], usize) + 'static) -> Self {
307            Self {
308                participants,
309                mutate: RefCell::new(Some(Box::new(mutate))),
310            }
311        }
312    }
313
314    impl ConsensusTransport for MockTransport {
315        type Error = Infallible;
316
317        fn participant_count(&self) -> usize {
318            self.participants
319        }
320
321        fn all_gather_words(&self, local: &[u32]) -> Result<Vec<u32>, Self::Error> {
322            let mut gathered = Vec::with_capacity(local.len() * self.participants);
323            for rank in 0..self.participants {
324                let start = gathered.len();
325                gathered.extend_from_slice(local);
326                if let Some(mutate) = self.mutate.borrow_mut().as_mut() {
327                    mutate(&mut gathered[start..], rank);
328                }
329            }
330            Ok(gathered)
331        }
332    }
333
334    #[test]
335    fn schedule_and_disposition_agree_without_backend_types() {
336        let transport = MockTransport::agreeing(3);
337        let descriptor = [7, 8, 9];
338        let work = [ScheduledWork {
339            id: WorkId::new(RequestId::new(5), 2),
340            descriptor: &descriptor,
341        }];
342        validate_schedule(&transport, &work, 11, 13).unwrap();
343        validate_disposition(
344            &transport,
345            13,
346            RequestId::new(5),
347            CancellationCause::Explicit,
348        )
349        .unwrap();
350    }
351
352    #[test]
353    fn schedule_mismatch_fails_closed() {
354        let transport = MockTransport::mutating(2, |words, rank| {
355            if rank == 1 {
356                *words.last_mut().unwrap() ^= 1;
357            }
358        });
359        let descriptor = [7];
360        let error = validate_schedule(
361            &transport,
362            &[ScheduledWork {
363                id: WorkId::new(RequestId::new(1), 0),
364                descriptor: &descriptor,
365            }],
366            0,
367            9,
368        )
369        .unwrap_err();
370        assert_eq!(
371            error,
372            ConsensusError::Mismatch {
373                context: "distributed work descriptors",
374                rank: 1,
375            }
376        );
377    }
378
379    #[test]
380    fn completion_resolution_waits_for_failed_rank_peers() {
381        let call = RefCell::new(0usize);
382        let transport = MockTransport::mutating(3, move |words, rank| {
383            if rank == 1 {
384                words[7] = CompletionObservation::Failed.wire();
385            } else if rank == 2 {
386                words[7] = CompletionObservation::Incomplete.wire();
387            }
388            *call.borrow_mut() += 1;
389        });
390        let resolutions = resolve_completions(
391            &transport,
392            22,
393            &[(
394                WorkId::new(RequestId::new(4), 3),
395                CompletionObservation::Complete,
396            )],
397        )
398        .unwrap();
399        assert_eq!(resolutions, vec![CompletionResolution::FailedPending]);
400    }
401
402    #[test]
403    fn malformed_gather_and_empty_topology_fail_closed() {
404        let empty = MockTransport::agreeing(0);
405        assert_eq!(
406            validate_disposition(&empty, 1, RequestId::new(1), CancellationCause::Deadline,)
407                .unwrap_err(),
408            ConsensusError::EmptyTopology
409        );
410
411        struct ShortGather;
412        impl ConsensusTransport for ShortGather {
413            type Error = Infallible;
414            fn participant_count(&self) -> usize {
415                2
416            }
417            fn all_gather_words(&self, local: &[u32]) -> Result<Vec<u32>, Self::Error> {
418                Ok(local.to_vec())
419            }
420        }
421        assert!(matches!(
422            validate_disposition(
423                &ShortGather,
424                1,
425                RequestId::new(1),
426                CancellationCause::Explicit,
427            ),
428            Err(ConsensusError::MalformedGather { .. })
429        ));
430    }
431}