Skip to main content

termivar_scanner/distributed/
coordinator.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::sync::{Arc, Mutex};
3
4use super::lease::{
5    ensure_current_lease, ensure_queue_entry, terminalize_leased_task, terminalize_queued_task,
6    validate_cancellation_ownership, CancellationOutcome, CancellationProof, CompletionOutcome,
7    CompletionReceipt, FailureOutcome, FailureProof, StartOutcome, TaskLease, TaskOwnership,
8};
9use super::limits::{validate_limits, DistributedLimits, UTILIZATION_BASIS_POINTS};
10use super::model::{
11    validate_task_command_id, validate_worker_command_id, StateSnapshot, TaskStatus, Transition,
12};
13use super::queue::{QueueKey, TaskEntry, TaskQueue};
14use super::recovery::{
15    apply_recovery, leased_task_ids, preflight_recovery, RecoveryDisposition, RecoverySummary,
16};
17use super::worker::{
18    apply_assignment, best_worker_id, ensure_observation_time, preflight_worker_release,
19    prepare_assignment, release_worker, validate_worker_spec, WorkerNode, WorkerObservation,
20    WorkerSpec, WorkerStatus,
21};
22use super::{lock_state, DistributedError};
23
24pub(super) struct CoordinatorState {
25    pub(super) limits: DistributedLimits,
26    pub(super) revision: u64,
27    pub(super) logical_time: u64,
28    pub(super) tasks: BTreeMap<String, TaskEntry>,
29    pub(super) queue: BTreeSet<QueueKey>,
30    pub(super) workers: BTreeMap<String, WorkerNode>,
31    pub(super) active_tasks: usize,
32    pub(super) terminal_tasks: usize,
33    pub(super) next_enqueue_ordinal: u64,
34    pub(super) next_lease_id: u64,
35}
36
37impl CoordinatorState {
38    pub(super) fn new(limits: DistributedLimits) -> Self {
39        Self {
40            limits,
41            revision: 0,
42            logical_time: 0,
43            tasks: BTreeMap::new(),
44            queue: BTreeSet::new(),
45            workers: BTreeMap::new(),
46            active_tasks: 0,
47            terminal_tasks: 0,
48            next_enqueue_ordinal: 1,
49            next_lease_id: 1,
50        }
51    }
52
53    pub(super) fn preflight_command(
54        &self,
55        expected_revision: u64,
56        now_secs: u64,
57    ) -> Result<u64, DistributedError> {
58        if expected_revision != self.revision {
59            return Err(DistributedError::RevisionConflict {
60                expected: expected_revision,
61                actual: self.revision,
62            });
63        }
64        if now_secs < self.logical_time {
65            return Err(DistributedError::LogicalTimeRegression {
66                current: self.logical_time,
67                proposed: now_secs,
68            });
69        }
70        self.revision
71            .checked_add(1)
72            .ok_or(DistributedError::CounterExhausted {
73                counter: "revision",
74            })
75    }
76
77    pub(super) fn commit(&mut self, revision: u64, now_secs: u64) {
78        self.revision = revision;
79        self.logical_time = now_secs;
80    }
81
82    pub(super) fn next_task_id(&self) -> Option<String> {
83        self.queue.first().map(|key| key.task_id.clone())
84    }
85
86    pub(super) fn snapshot(&self) -> StateSnapshot {
87        StateSnapshot {
88            revision: self.revision,
89            logical_time: self.logical_time,
90            task_records: self.tasks.len(),
91            active_tasks: self.active_tasks,
92            queued_tasks: self.queue.len(),
93            terminal_tasks: self.terminal_tasks,
94            workers: self.workers.len(),
95        }
96    }
97}
98
99pub(super) fn next_counter(value: u64, counter: &'static str) -> Result<u64, DistributedError> {
100    value
101        .checked_add(1)
102        .ok_or(DistributedError::CounterExhausted { counter })
103}
104
105pub(super) fn next_u32(value: u32, counter: &'static str) -> Result<u32, DistributedError> {
106    value
107        .checked_add(1)
108        .ok_or(DistributedError::CounterExhausted { counter })
109}
110
111/// Atomic worker/task coordinator. All clones share one revisioned state lock.
112#[derive(Clone)]
113pub struct WorkerPool {
114    state: Arc<Mutex<CoordinatorState>>,
115    task_queue: TaskQueue,
116}
117
118impl WorkerPool {
119    pub fn new() -> Self {
120        let state = Arc::new(Mutex::new(CoordinatorState::new(
121            DistributedLimits::default(),
122        )));
123        Self {
124            task_queue: TaskQueue {
125                state: Arc::clone(&state),
126            },
127            state,
128        }
129    }
130
131    pub fn with_limits(limits: DistributedLimits) -> Result<Self, DistributedError> {
132        validate_limits(limits)?;
133        let state = Arc::new(Mutex::new(CoordinatorState::new(limits)));
134        Ok(Self {
135            task_queue: TaskQueue {
136                state: Arc::clone(&state),
137            },
138            state,
139        })
140    }
141
142    pub fn snapshot(&self) -> Result<StateSnapshot, DistributedError> {
143        Ok(lock_state(&self.state)?.snapshot())
144    }
145
146    /// Return a cloneable task facade guaranteed to share this pool's state.
147    pub fn task_queue(&self) -> TaskQueue {
148        self.task_queue.clone()
149    }
150
151    /// Register a never-before-seen worker. Duplicate IDs never overwrite.
152    pub fn register_worker(
153        &self,
154        expected_revision: u64,
155        now_secs: u64,
156        spec: WorkerSpec,
157    ) -> Result<Transition<WorkerNode>, DistributedError> {
158        validate_worker_spec(&spec)?;
159        let mut state = lock_state(&self.state)?;
160        let revision = state.preflight_command(expected_revision, now_secs)?;
161        if state.workers.contains_key(&spec.worker_id) {
162            return Err(DistributedError::WorkerAlreadyExists {
163                worker_id: spec.worker_id,
164            });
165        }
166        if state.workers.len() >= state.limits.max_workers {
167            return Err(DistributedError::WorkerCapacityReached {
168                limit: state.limits.max_workers,
169            });
170        }
171        let worker = WorkerNode {
172            worker_id: spec.worker_id,
173            generation: 1,
174            status: WorkerStatus::Healthy,
175            capacity: spec.capacity,
176            current_tasks: 0,
177            completed_tasks: 0,
178            last_heartbeat: now_secs,
179            cpu_basis_points: spec.cpu_basis_points,
180            memory_basis_points: spec.memory_basis_points,
181            network_basis_points: spec.network_basis_points,
182            tags: spec.tags,
183        };
184        state
185            .workers
186            .insert(worker.worker_id.clone(), worker.clone());
187        state.commit(revision, now_secs);
188        Ok(Transition {
189            revision,
190            value: worker,
191        })
192    }
193
194    /// Reactivate an offline worker under a new generation fence.
195    pub fn reactivate_worker(
196        &self,
197        expected_revision: u64,
198        now_secs: u64,
199        expected_generation: u64,
200        spec: WorkerSpec,
201    ) -> Result<Transition<WorkerNode>, DistributedError> {
202        validate_worker_spec(&spec)?;
203        let mut state = lock_state(&self.state)?;
204        let revision = state.preflight_command(expected_revision, now_secs)?;
205        let current = state.workers.get(&spec.worker_id).cloned().ok_or_else(|| {
206            DistributedError::WorkerNotFound {
207                worker_id: spec.worker_id.clone(),
208            }
209        })?;
210        if current.generation != expected_generation {
211            return Err(DistributedError::WorkerGenerationConflict {
212                expected: expected_generation,
213                actual: current.generation,
214            });
215        }
216        if current.status != WorkerStatus::Offline || current.current_tasks != 0 {
217            return Err(DistributedError::WorkerUnavailable {
218                worker_id: spec.worker_id,
219            });
220        }
221        let generation = next_counter(current.generation, "worker_generation")?;
222        let worker = WorkerNode {
223            worker_id: spec.worker_id,
224            generation,
225            status: WorkerStatus::Healthy,
226            capacity: spec.capacity,
227            current_tasks: 0,
228            completed_tasks: current.completed_tasks,
229            last_heartbeat: now_secs,
230            cpu_basis_points: spec.cpu_basis_points,
231            memory_basis_points: spec.memory_basis_points,
232            network_basis_points: spec.network_basis_points,
233            tags: spec.tags,
234        };
235        state
236            .workers
237            .insert(worker.worker_id.clone(), worker.clone());
238        state.commit(revision, now_secs);
239        Ok(Transition {
240            revision,
241            value: worker,
242        })
243    }
244
245    /// Update heartbeat, eligibility state, and integer resource observations.
246    pub fn update_worker(
247        &self,
248        expected_revision: u64,
249        now_secs: u64,
250        worker_id: &str,
251        worker_generation: u64,
252        observation: WorkerObservation,
253    ) -> Result<Transition<WorkerNode>, DistributedError> {
254        validate_worker_command_id(worker_id)?;
255        if observation.status == WorkerStatus::Offline {
256            return Err(DistributedError::InvalidWorker {
257                reason: "offline transition requires deregister or prune",
258            });
259        }
260        if observation.cpu_basis_points > UTILIZATION_BASIS_POINTS
261            || observation.memory_basis_points > UTILIZATION_BASIS_POINTS
262            || observation.network_basis_points > UTILIZATION_BASIS_POINTS
263        {
264            return Err(DistributedError::InvalidWorker {
265                reason: "utilization exceeds 10000 basis points",
266            });
267        }
268        let mut state = lock_state(&self.state)?;
269        let revision = state.preflight_command(expected_revision, now_secs)?;
270        let current = state.workers.get(worker_id).cloned().ok_or_else(|| {
271            DistributedError::WorkerNotFound {
272                worker_id: worker_id.to_owned(),
273            }
274        })?;
275        if current.generation != worker_generation {
276            return Err(DistributedError::WorkerGenerationConflict {
277                expected: worker_generation,
278                actual: current.generation,
279            });
280        }
281        if current.status == WorkerStatus::Offline {
282            return Err(DistributedError::WorkerUnavailable {
283                worker_id: worker_id.to_owned(),
284            });
285        }
286        let worker = state
287            .workers
288            .get_mut(worker_id)
289            .ok_or(DistributedError::StateInvariant {
290                reason: "worker disappeared during update",
291            })?;
292        worker.status = observation.status;
293        worker.last_heartbeat = now_secs;
294        worker.cpu_basis_points = observation.cpu_basis_points;
295        worker.memory_basis_points = observation.memory_basis_points;
296        worker.network_basis_points = observation.network_basis_points;
297        let snapshot = worker.clone();
298        state.commit(revision, now_secs);
299        Ok(Transition {
300            revision,
301            value: snapshot,
302        })
303    }
304
305    /// Return the deterministically selected eligible worker at logical time.
306    pub fn get_available_worker(
307        &self,
308        now_secs: u64,
309    ) -> Result<Option<WorkerNode>, DistributedError> {
310        let state = lock_state(&self.state)?;
311        ensure_observation_time(&state, now_secs)?;
312        Ok(best_worker_id(&state, now_secs)
313            .and_then(|worker_id| state.workers.get(&worker_id).cloned()))
314    }
315
316    /// Atomically assign a specific queued task to a specific worker.
317    pub fn assign_task(
318        &self,
319        expected_revision: u64,
320        now_secs: u64,
321        task_id: &str,
322        worker_id: &str,
323        lease_ttl_secs: u64,
324    ) -> Result<Transition<TaskLease>, DistributedError> {
325        validate_task_command_id(task_id)?;
326        validate_worker_command_id(worker_id)?;
327        let mut state = lock_state(&self.state)?;
328        let revision = state.preflight_command(expected_revision, now_secs)?;
329        let lease = prepare_assignment(&state, task_id, worker_id, now_secs, lease_ttl_secs)?;
330        apply_assignment(&mut state, &lease)?;
331        state.commit(revision, now_secs);
332        Ok(Transition {
333            revision,
334            value: lease,
335        })
336    }
337
338    /// Atomically assign the highest-priority FIFO task to a specific worker.
339    pub fn assign_next(
340        &self,
341        expected_revision: u64,
342        now_secs: u64,
343        worker_id: &str,
344        lease_ttl_secs: u64,
345    ) -> Result<Transition<TaskLease>, DistributedError> {
346        validate_worker_command_id(worker_id)?;
347        let mut state = lock_state(&self.state)?;
348        let revision = state.preflight_command(expected_revision, now_secs)?;
349        let task_id = state.next_task_id().ok_or(DistributedError::NoQueuedTask)?;
350        let lease = prepare_assignment(&state, &task_id, worker_id, now_secs, lease_ttl_secs)?;
351        apply_assignment(&mut state, &lease)?;
352        state.commit(revision, now_secs);
353        Ok(Transition {
354            revision,
355            value: lease,
356        })
357    }
358
359    /// Atomically choose both task and worker. Equal worker keys choose the
360    /// lexicographically smallest worker ID.
361    pub fn assign_next_available(
362        &self,
363        expected_revision: u64,
364        now_secs: u64,
365        lease_ttl_secs: u64,
366    ) -> Result<Transition<TaskLease>, DistributedError> {
367        let mut state = lock_state(&self.state)?;
368        let revision = state.preflight_command(expected_revision, now_secs)?;
369        let task_id = state.next_task_id().ok_or(DistributedError::NoQueuedTask)?;
370        let worker_id =
371            best_worker_id(&state, now_secs).ok_or(DistributedError::NoAvailableWorker)?;
372        let lease = prepare_assignment(&state, &task_id, &worker_id, now_secs, lease_ttl_secs)?;
373        apply_assignment(&mut state, &lease)?;
374        state.commit(revision, now_secs);
375        Ok(Transition {
376            revision,
377            value: lease,
378        })
379    }
380
381    /// Transition a lease from `Leased` to `Running`. Exact replay is a no-op.
382    pub fn start_task(
383        &self,
384        expected_revision: u64,
385        now_secs: u64,
386        lease: &TaskLease,
387    ) -> Result<Transition<StartOutcome>, DistributedError> {
388        let mut state = lock_state(&self.state)?;
389        let revision = state.preflight_command(expected_revision, now_secs)?;
390        let entry =
391            state
392                .tasks
393                .get(&lease.task_id)
394                .ok_or_else(|| DistributedError::TaskNotFound {
395                    task_id: lease.task_id.clone(),
396                })?;
397        ensure_current_lease(entry, lease, now_secs)?;
398        if entry.task.status == TaskStatus::Running {
399            return Ok(Transition {
400                revision: state.revision,
401                value: StartOutcome::AlreadyRunning {
402                    record_version: entry.task.record_version,
403                },
404            });
405        }
406        if entry.task.status != TaskStatus::Leased {
407            return Err(DistributedError::InvalidTransition {
408                task_id: lease.task_id.clone(),
409                status: entry.task.status,
410                operation: "start",
411            });
412        }
413        let record_version = next_counter(entry.task.record_version, "record_version")?;
414        let entry =
415            state
416                .tasks
417                .get_mut(&lease.task_id)
418                .ok_or(DistributedError::StateInvariant {
419                    reason: "task disappeared during start",
420                })?;
421        entry.task.status = TaskStatus::Running;
422        entry.task.started_at = Some(now_secs);
423        entry.task.record_version = record_version;
424        state.commit(revision, now_secs);
425        Ok(Transition {
426            revision,
427            value: StartOutcome::Started { record_version },
428        })
429    }
430
431    /// Complete only the current lease. Exact replay returns the same receipt
432    /// without changing revision or worker counters.
433    pub fn complete_task(
434        &self,
435        expected_revision: u64,
436        now_secs: u64,
437        lease: &TaskLease,
438    ) -> Result<Transition<CompletionOutcome>, DistributedError> {
439        let mut state = lock_state(&self.state)?;
440        let revision = state.preflight_command(expected_revision, now_secs)?;
441        let entry =
442            state
443                .tasks
444                .get(&lease.task_id)
445                .ok_or_else(|| DistributedError::TaskNotFound {
446                    task_id: lease.task_id.clone(),
447                })?;
448        if entry.task.status == TaskStatus::Completed {
449            return match entry.completion.as_ref() {
450                Some(receipt) if receipt.matches_lease(lease) => Ok(Transition {
451                    revision: state.revision,
452                    value: CompletionOutcome::AlreadyCompleted(receipt.clone()),
453                }),
454                _ => Err(DistributedError::StaleOwnership {
455                    task_id: lease.task_id.clone(),
456                }),
457            };
458        }
459        ensure_current_lease(entry, lease, now_secs)?;
460        if entry.task.status != TaskStatus::Running {
461            return Err(DistributedError::InvalidTransition {
462                task_id: lease.task_id.clone(),
463                status: entry.task.status,
464                operation: "complete",
465            });
466        }
467        let record_version = next_counter(entry.task.record_version, "record_version")?;
468        preflight_worker_release(&state, lease)?;
469        let completed_tasks = state
470            .workers
471            .get(&lease.worker_id)
472            .ok_or(DistributedError::StateInvariant {
473                reason: "completion worker disappeared during preflight",
474            })?
475            .completed_tasks
476            .checked_add(1)
477            .ok_or(DistributedError::CounterExhausted {
478                counter: "worker_completed_tasks",
479            })?;
480        let receipt = CompletionReceipt {
481            task_id: lease.task_id.clone(),
482            task_generation: lease.task_generation,
483            attempt: lease.attempt,
484            lease_id: lease.lease_id,
485            worker_id: lease.worker_id.clone(),
486            worker_generation: lease.worker_generation,
487            record_version,
488        };
489        terminalize_leased_task(
490            &mut state,
491            lease,
492            TaskStatus::Completed,
493            now_secs,
494            record_version,
495        )?;
496        let entry =
497            state
498                .tasks
499                .get_mut(&lease.task_id)
500                .ok_or(DistributedError::StateInvariant {
501                    reason: "task disappeared during completion",
502                })?;
503        entry.completion = Some(receipt.clone());
504        let worker =
505            state
506                .workers
507                .get_mut(&lease.worker_id)
508                .ok_or(DistributedError::StateInvariant {
509                    reason: "worker disappeared during completion",
510                })?;
511        worker.completed_tasks = completed_tasks;
512        state.commit(revision, now_secs);
513        Ok(Transition {
514            revision,
515            value: CompletionOutcome::Completed(receipt),
516        })
517    }
518
519    /// Cancel queued or leased work only with its exact ownership fence.
520    /// Exact replay is a no-op; a different token fails closed.
521    pub fn cancel_task(
522        &self,
523        expected_revision: u64,
524        now_secs: u64,
525        ownership: &TaskOwnership,
526    ) -> Result<Transition<CancellationOutcome>, DistributedError> {
527        let task_id = ownership.task_id().to_owned();
528        let mut state = lock_state(&self.state)?;
529        let revision = state.preflight_command(expected_revision, now_secs)?;
530        let entry = state
531            .tasks
532            .get(&task_id)
533            .ok_or_else(|| DistributedError::TaskNotFound {
534                task_id: task_id.clone(),
535            })?;
536        if entry.task.status == TaskStatus::Cancelled {
537            return match entry.cancellation.as_ref() {
538                Some(proof) if proof.matches(ownership) => Ok(Transition {
539                    revision: state.revision,
540                    value: CancellationOutcome::AlreadyCancelled {
541                        record_version: entry.task.record_version,
542                    },
543                }),
544                _ => Err(DistributedError::StaleOwnership { task_id }),
545            };
546        }
547        validate_cancellation_ownership(entry, ownership, now_secs)?;
548        let record_version = next_counter(entry.task.record_version, "record_version")?;
549        let proof = match ownership {
550            TaskOwnership::Queued(fence) => CancellationProof::Queued(fence.clone()),
551            TaskOwnership::Leased(lease) => {
552                preflight_worker_release(&state, lease)?;
553                CancellationProof::Leased(lease.clone())
554            },
555        };
556        match ownership {
557            TaskOwnership::Queued(_) => terminalize_queued_task(
558                &mut state,
559                &task_id,
560                TaskStatus::Cancelled,
561                now_secs,
562                record_version,
563            )?,
564            TaskOwnership::Leased(lease) => terminalize_leased_task(
565                &mut state,
566                lease,
567                TaskStatus::Cancelled,
568                now_secs,
569                record_version,
570            )?,
571        }
572        let entry = state
573            .tasks
574            .get_mut(&task_id)
575            .ok_or(DistributedError::StateInvariant {
576                reason: "task disappeared during cancellation",
577            })?;
578        entry.cancellation = Some(proof);
579        state.commit(revision, now_secs);
580        Ok(Transition {
581            revision,
582            value: CancellationOutcome::Cancelled { record_version },
583        })
584    }
585
586    /// Fail the current attempt. Retry policy is fixed in [`DistributedLimits`].
587    pub fn fail_task(
588        &self,
589        expected_revision: u64,
590        now_secs: u64,
591        lease: &TaskLease,
592    ) -> Result<Transition<FailureOutcome>, DistributedError> {
593        let mut state = lock_state(&self.state)?;
594        let revision = state.preflight_command(expected_revision, now_secs)?;
595        let entry =
596            state
597                .tasks
598                .get(&lease.task_id)
599                .ok_or_else(|| DistributedError::TaskNotFound {
600                    task_id: lease.task_id.clone(),
601                })?;
602        if entry.task.status == TaskStatus::Failed {
603            return match entry.failure.as_ref() {
604                Some(proof) if proof.lease == *lease => Ok(Transition {
605                    revision: state.revision,
606                    value: proof.outcome.clone(),
607                }),
608                _ => Err(DistributedError::StaleOwnership {
609                    task_id: lease.task_id.clone(),
610                }),
611            };
612        }
613        ensure_current_lease(entry, lease, now_secs)?;
614        if entry.task.status != TaskStatus::Running {
615            return Err(DistributedError::InvalidTransition {
616                task_id: lease.task_id.clone(),
617                status: entry.task.status,
618                operation: "fail",
619            });
620        }
621        preflight_worker_release(&state, lease)?;
622        let record_version = next_counter(entry.task.record_version, "record_version")?;
623        if entry.task.retry_count < state.limits.max_retries {
624            if state.queue.len() >= state.limits.max_queued_tasks {
625                return Err(DistributedError::QueuedTaskCapacityReached {
626                    limit: state.limits.max_queued_tasks,
627                });
628            }
629            let retry_count = next_u32(entry.task.retry_count, "retry_count")?;
630            let task_generation = next_counter(entry.task.task_generation, "task_generation")?;
631            let enqueue_ordinal = state.next_enqueue_ordinal;
632            let next_ordinal = next_counter(enqueue_ordinal, "enqueue_ordinal")?;
633            release_worker(&mut state, lease)?;
634            let entry =
635                state
636                    .tasks
637                    .get_mut(&lease.task_id)
638                    .ok_or(DistributedError::StateInvariant {
639                        reason: "task disappeared during retry",
640                    })?;
641            entry.task.status = TaskStatus::Queued;
642            entry.task.assigned_to = None;
643            entry.task.lease = None;
644            entry.task.started_at = None;
645            entry.task.retry_count = retry_count;
646            entry.task.task_generation = task_generation;
647            entry.task.record_version = record_version;
648            let key = QueueKey::new(entry.task.priority, enqueue_ordinal, lease.task_id.clone());
649            entry.queue_key = Some(key.clone());
650            state.queue.insert(key);
651            state.next_enqueue_ordinal = next_ordinal;
652            state.commit(revision, now_secs);
653            Ok(Transition {
654                revision,
655                value: FailureOutcome::Requeued {
656                    task_generation,
657                    retry_count,
658                    record_version,
659                },
660            })
661        } else {
662            let outcome = FailureOutcome::RetryExhausted {
663                retry_count: entry.task.retry_count,
664                record_version,
665            };
666            terminalize_leased_task(
667                &mut state,
668                lease,
669                TaskStatus::Failed,
670                now_secs,
671                record_version,
672            )?;
673            let entry =
674                state
675                    .tasks
676                    .get_mut(&lease.task_id)
677                    .ok_or(DistributedError::StateInvariant {
678                        reason: "failed task disappeared",
679                    })?;
680            entry.failure = Some(FailureProof {
681                lease: lease.clone(),
682                outcome: outcome.clone(),
683            });
684            state.commit(revision, now_secs);
685            Ok(Transition {
686                revision,
687                value: outcome,
688            })
689        }
690    }
691
692    /// Mark a worker offline, fence its generation, and recover every lease it
693    /// owned in original lease order under the fixed retry policy.
694    pub fn deregister_worker(
695        &self,
696        expected_revision: u64,
697        now_secs: u64,
698        worker_id: &str,
699        worker_generation: u64,
700    ) -> Result<Transition<RecoverySummary>, DistributedError> {
701        validate_worker_command_id(worker_id)?;
702        let mut state = lock_state(&self.state)?;
703        let revision = state.preflight_command(expected_revision, now_secs)?;
704        let worker = state.workers.get(worker_id).cloned().ok_or_else(|| {
705            DistributedError::WorkerNotFound {
706                worker_id: worker_id.to_owned(),
707            }
708        })?;
709        if worker.generation != worker_generation {
710            return Err(DistributedError::WorkerGenerationConflict {
711                expected: worker_generation,
712                actual: worker.generation,
713            });
714        }
715        if worker.status == WorkerStatus::Offline {
716            return Err(DistributedError::WorkerUnavailable {
717                worker_id: worker_id.to_owned(),
718            });
719        }
720        let next_generation = next_counter(worker.generation, "worker_generation")?;
721        let task_ids = leased_task_ids(&state, |lease| lease.worker_id == worker_id);
722        preflight_recovery(&state, &task_ids)?;
723        let mut tasks_requeued = 0usize;
724        let mut tasks_failed = 0usize;
725        for task_id in &task_ids {
726            match apply_recovery(&mut state, task_id, now_secs)? {
727                RecoveryDisposition::Requeued => tasks_requeued += 1,
728                RecoveryDisposition::Failed => tasks_failed += 1,
729            }
730        }
731        let worker = state
732            .workers
733            .get_mut(worker_id)
734            .ok_or(DistributedError::StateInvariant {
735                reason: "worker disappeared during deregistration",
736            })?;
737        if worker.current_tasks != 0 {
738            return Err(DistributedError::StateInvariant {
739                reason: "worker retained active tasks after recovery",
740            });
741        }
742        worker.status = WorkerStatus::Offline;
743        worker.generation = next_generation;
744        state.commit(revision, now_secs);
745        Ok(Transition {
746            revision,
747            value: RecoverySummary {
748                workers_affected: 1,
749                tasks_requeued,
750                tasks_failed,
751            },
752        })
753    }
754
755    /// Recover every lease at or beyond its deadline (`now >= expires_at`) under
756    /// the fixed retry policy.
757    pub fn recover_expired_leases(
758        &self,
759        expected_revision: u64,
760        now_secs: u64,
761    ) -> Result<Transition<RecoverySummary>, DistributedError> {
762        let mut state = lock_state(&self.state)?;
763        let revision = state.preflight_command(expected_revision, now_secs)?;
764        let task_ids = leased_task_ids(&state, |lease| now_secs >= lease.expires_at);
765        preflight_recovery(&state, &task_ids)?;
766        let workers: BTreeSet<String> = task_ids
767            .iter()
768            .filter_map(|task_id| {
769                state
770                    .tasks
771                    .get(task_id)
772                    .and_then(|entry| entry.task.assigned_to.clone())
773            })
774            .collect();
775        let mut tasks_requeued = 0usize;
776        let mut tasks_failed = 0usize;
777        for task_id in &task_ids {
778            match apply_recovery(&mut state, task_id, now_secs)? {
779                RecoveryDisposition::Requeued => tasks_requeued += 1,
780                RecoveryDisposition::Failed => tasks_failed += 1,
781            }
782        }
783        state.commit(revision, now_secs);
784        Ok(Transition {
785            revision,
786            value: RecoverySummary {
787                workers_affected: workers.len(),
788                tasks_requeued,
789                tasks_failed,
790            },
791        })
792    }
793
794    /// Mark heartbeat-stale workers offline and recover their leases. Stale
795    /// workers are already ineligible for assignment before this command runs.
796    pub fn prune_dead_workers(
797        &self,
798        expected_revision: u64,
799        now_secs: u64,
800    ) -> Result<Transition<RecoverySummary>, DistributedError> {
801        let mut state = lock_state(&self.state)?;
802        let revision = state.preflight_command(expected_revision, now_secs)?;
803        let dead_workers: Vec<String> = state
804            .workers
805            .iter()
806            .filter(|(_, worker)| {
807                worker.status != WorkerStatus::Offline
808                    && now_secs.saturating_sub(worker.last_heartbeat)
809                        > state.limits.heartbeat_timeout_secs
810            })
811            .map(|(worker_id, _)| worker_id.clone())
812            .collect();
813        for worker_id in &dead_workers {
814            let worker = state
815                .workers
816                .get(worker_id)
817                .ok_or(DistributedError::StateInvariant {
818                    reason: "dead worker disappeared during preflight",
819                })?;
820            next_counter(worker.generation, "worker_generation")?;
821        }
822        let dead_set: BTreeSet<&str> = dead_workers.iter().map(String::as_str).collect();
823        let task_ids = leased_task_ids(&state, |lease| dead_set.contains(lease.worker_id.as_str()));
824        preflight_recovery(&state, &task_ids)?;
825        let mut tasks_requeued = 0usize;
826        let mut tasks_failed = 0usize;
827        for task_id in &task_ids {
828            match apply_recovery(&mut state, task_id, now_secs)? {
829                RecoveryDisposition::Requeued => tasks_requeued += 1,
830                RecoveryDisposition::Failed => tasks_failed += 1,
831            }
832        }
833        for worker_id in &dead_workers {
834            let worker =
835                state
836                    .workers
837                    .get_mut(worker_id)
838                    .ok_or(DistributedError::StateInvariant {
839                        reason: "dead worker disappeared during prune",
840                    })?;
841            if worker.current_tasks != 0 {
842                return Err(DistributedError::StateInvariant {
843                    reason: "pruned worker retained active tasks",
844                });
845            }
846            worker.status = WorkerStatus::Offline;
847            worker.generation = next_counter(worker.generation, "worker_generation")?;
848        }
849        state.commit(revision, now_secs);
850        Ok(Transition {
851            revision,
852            value: RecoverySummary {
853                workers_affected: dead_workers.len(),
854                tasks_requeued,
855                tasks_failed,
856            },
857        })
858    }
859
860    /// Terminally expire every active task whose age reaches `task_ttl_secs`.
861    pub fn expire_old_tasks(
862        &self,
863        expected_revision: u64,
864        now_secs: u64,
865        task_ttl_secs: u64,
866    ) -> Result<Transition<usize>, DistributedError> {
867        if task_ttl_secs == 0 {
868            return Err(DistributedError::InvalidLimit {
869                name: "task_ttl_secs",
870            });
871        }
872        let mut state = lock_state(&self.state)?;
873        let revision = state.preflight_command(expected_revision, now_secs)?;
874        if task_ttl_secs > state.limits.max_task_ttl_secs {
875            return Err(DistributedError::InvalidLimitRelationship {
876                reason: "task_ttl_secs exceeds configured maximum",
877            });
878        }
879        let task_ids: Vec<String> = state
880            .tasks
881            .iter()
882            .filter(|(_, entry)| {
883                entry.task.status.is_active() && entry.task.age_secs(now_secs) >= task_ttl_secs
884            })
885            .map(|(task_id, _)| task_id.clone())
886            .collect();
887        for task_id in &task_ids {
888            let entry = state
889                .tasks
890                .get(task_id)
891                .ok_or(DistributedError::StateInvariant {
892                    reason: "expiring task disappeared during preflight",
893                })?;
894            next_counter(entry.task.record_version, "record_version")?;
895            match entry.task.status {
896                TaskStatus::Queued => ensure_queue_entry(&state, entry)?,
897                TaskStatus::Leased | TaskStatus::Running => {
898                    let lease =
899                        entry
900                            .task
901                            .lease
902                            .as_ref()
903                            .ok_or(DistributedError::StateInvariant {
904                                reason: "leased task has no lease during expiry",
905                            })?;
906                    preflight_worker_release(&state, lease)?;
907                },
908                _ => {},
909            }
910        }
911        for task_id in &task_ids {
912            let task = state
913                .tasks
914                .get(task_id)
915                .map(|entry| entry.task.clone())
916                .ok_or(DistributedError::StateInvariant {
917                    reason: "expiring task disappeared",
918                })?;
919            let record_version = next_counter(task.record_version, "record_version")?;
920            match task.status {
921                TaskStatus::Queued => terminalize_queued_task(
922                    &mut state,
923                    task_id,
924                    TaskStatus::Expired,
925                    now_secs,
926                    record_version,
927                )?,
928                TaskStatus::Leased | TaskStatus::Running => {
929                    let lease = task
930                        .lease
931                        .as_ref()
932                        .ok_or(DistributedError::StateInvariant {
933                            reason: "leased task lost lease during expiry",
934                        })?;
935                    terminalize_leased_task(
936                        &mut state,
937                        lease,
938                        TaskStatus::Expired,
939                        now_secs,
940                        record_version,
941                    )?;
942                },
943                _ => {},
944            }
945        }
946        state.commit(revision, now_secs);
947        Ok(Transition {
948            revision,
949            value: task_ids.len(),
950        })
951    }
952
953    /// Return workers in stable worker-ID order.
954    pub fn get_workers(&self) -> Result<Vec<WorkerNode>, DistributedError> {
955        Ok(lock_state(&self.state)?.workers.values().cloned().collect())
956    }
957
958    pub fn get_worker(&self, worker_id: &str) -> Result<Option<WorkerNode>, DistributedError> {
959        validate_worker_command_id(worker_id)?;
960        Ok(lock_state(&self.state)?.workers.get(worker_id).cloned())
961    }
962
963    /// Actively verify all cross-record invariants.
964    pub fn check_invariants(&self) -> Result<(), DistributedError> {
965        let state = lock_state(&self.state)?;
966        check_state_invariants(&state)
967    }
968}
969
970impl Default for WorkerPool {
971    fn default() -> Self {
972        Self::new()
973    }
974}
975
976fn check_state_invariants(state: &CoordinatorState) -> Result<(), DistributedError> {
977    if state.tasks.len() > state.limits.max_task_records
978        || state.active_tasks > state.limits.max_active_tasks
979        || state.queue.len() > state.limits.max_queued_tasks
980        || state.terminal_tasks > state.limits.max_terminal_tasks
981        || state.workers.len() > state.limits.max_workers
982    {
983        return Err(DistributedError::StateInvariant {
984            reason: "configured bound was exceeded",
985        });
986    }
987    let active = state
988        .tasks
989        .values()
990        .filter(|entry| entry.task.status.is_active())
991        .count();
992    let terminal = state.tasks.len().saturating_sub(active);
993    if active != state.active_tasks || terminal != state.terminal_tasks {
994        return Err(DistributedError::StateInvariant {
995            reason: "task counters disagree with records",
996        });
997    }
998    let mut observed_queue = BTreeSet::new();
999    let mut worker_leases: BTreeMap<(&str, u64), u32> = BTreeMap::new();
1000    for entry in state.tasks.values() {
1001        if entry.task.record_version == 0 {
1002            return Err(DistributedError::StateInvariant {
1003                reason: "retained task has zero record version",
1004            });
1005        }
1006        match entry.task.status {
1007            TaskStatus::Queued => {
1008                let key = entry
1009                    .queue_key
1010                    .as_ref()
1011                    .ok_or(DistributedError::StateInvariant {
1012                        reason: "queued task has no queue key",
1013                    })?;
1014                if key.task_id != entry.task.task_id || !observed_queue.insert(key.clone()) {
1015                    return Err(DistributedError::StateInvariant {
1016                        reason: "queued task has duplicate or mismatched queue key",
1017                    });
1018                }
1019                if entry.task.lease.is_some() || entry.task.assigned_to.is_some() {
1020                    return Err(DistributedError::StateInvariant {
1021                        reason: "queued task retains lease ownership",
1022                    });
1023                }
1024            },
1025            TaskStatus::Leased | TaskStatus::Running => {
1026                if entry.queue_key.is_some() {
1027                    return Err(DistributedError::StateInvariant {
1028                        reason: "leased task still has a queue key",
1029                    });
1030                }
1031                let lease = entry
1032                    .task
1033                    .lease
1034                    .as_ref()
1035                    .ok_or(DistributedError::StateInvariant {
1036                        reason: "leased task has no lease",
1037                    })?;
1038                if entry.task.assigned_to.as_deref() != Some(lease.worker_id.as_str())
1039                    || entry.task.task_generation != lease.task_generation
1040                    || entry.task.attempt != lease.attempt
1041                {
1042                    return Err(DistributedError::StateInvariant {
1043                        reason: "task record disagrees with its lease",
1044                    });
1045                }
1046                let count = worker_leases
1047                    .entry((&lease.worker_id, lease.worker_generation))
1048                    .or_default();
1049                *count = next_u32(*count, "invariant_worker_leases")?;
1050            },
1051            _ => {
1052                if entry.queue_key.is_some()
1053                    || entry.task.lease.is_some()
1054                    || entry.task.assigned_to.is_some()
1055                {
1056                    return Err(DistributedError::StateInvariant {
1057                        reason: "terminal task retains active ownership",
1058                    });
1059                }
1060            },
1061        }
1062    }
1063    if observed_queue != state.queue {
1064        return Err(DistributedError::StateInvariant {
1065            reason: "queue keys disagree with queued task records",
1066        });
1067    }
1068    for worker in state.workers.values() {
1069        let expected = match worker_leases.get(&(worker.worker_id.as_str(), worker.generation)) {
1070            Some(count) => *count,
1071            None => 0,
1072        };
1073        if worker.current_tasks != expected || worker.current_tasks > worker.capacity {
1074            return Err(DistributedError::StateInvariant {
1075                reason: "worker counter disagrees with active leases",
1076            });
1077        }
1078    }
1079    for ((worker_id, generation), _) in worker_leases {
1080        let worker = state
1081            .workers
1082            .get(worker_id)
1083            .ok_or(DistributedError::StateInvariant {
1084                reason: "lease references an unknown worker",
1085            })?;
1086        if worker.generation != generation {
1087            return Err(DistributedError::StateInvariant {
1088                reason: "lease references a stale worker generation",
1089            });
1090        }
1091    }
1092    Ok(())
1093}