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#[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 pub fn task_queue(&self) -> TaskQueue {
148 self.task_queue.clone()
149 }
150
151 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}