Skip to main content

oximedia_distributed/
lib.rs

1//! Distributed encoding coordinator for `OxiMedia`.
2//!
3//! This crate provides a distributed video encoding system with:
4//! - Central coordinator for job management
5//! - Worker nodes for distributed encoding
6//! - Multiple splitting strategies (segment, tile, GOP-based)
7//! - Load balancing and fault tolerance
8//! - TCP-based coordinator control server (JSON protocol)
9//! - WebSocket-style real-time job event notifications (broadcast channel)
10//! - Cross-region geo-aware task placement
11//! - S3/object-storage segment I/O (behind `s3` feature)
12//! - Kubernetes HPA auto-scaling hooks (behind `k8s` feature)
13//! - Raft consensus primitives with latency profiling
14//!
15//! # gRPC / TCP API
16//!
17//! The coordinator exposes a plain-text TCP control server on the configured
18//! `coordinator_addr`.  Connect with any TCP client and send newline-terminated
19//! commands:
20//!
21//! | Command  | Response                                    |
22//! |----------|---------------------------------------------|
23//! | `status` | JSON object with aggregate counters         |
24//! | `nodes`  | JSON array of registered workers            |
25//! | `jobs`   | JSON array of in-flight jobs with progress  |
26//!
27//! The protobuf/gRPC types live in [`pb`] and the full `CoordinatorService`
28//! gRPC implementation is in `coordinator::CoordinatorServiceImpl`.
29//!
30//! # Deployment Architecture
31//!
32//! ```text
33//! ┌─────────────────────────────────────────────────────────┐
34//! │                        Client                           │
35//! │   submit_job / job_status / cancel_job (TCP JSON RPC)   │
36//! └────────────────────────┬────────────────────────────────┘
37//!                          │
38//!                          ▼
39//! ┌─────────────────────────────────────────────────────────┐
40//! │              Coordinator (DistributedEncoder)            │
41//! │  ┌──────────────┐  ┌──────────────┐  ┌─────────────┐  │
42//! │  │ Job Scheduler│  │ Health Check │  │Circuit Break│  │
43//! │  │ (backpressure│  │    Loop      │  │    er       │  │
44//! │  │  / priority) │  │(90s timeout) │  │             │  │
45//! │  └──────────────┘  └──────────────┘  └─────────────┘  │
46//! │  ┌──────────────────────────────────────────────────┐  │
47//! │  │          NotificationBus (broadcast::channel)     │  │
48//! │  └──────────────────────────────────────────────────┘  │
49//! └────────────────────────┬────────────────────────────────┘
50//!                          │   assign / heartbeat / result
51//!          ┌───────────────┼────────────────────┐
52//!          ▼               ▼                    ▼
53//! ┌─────────────┐  ┌─────────────┐    ┌─────────────┐
54//! │  Worker 1   │  │  Worker 2   │ …  │  Worker N   │
55//! │ (us-east-1) │  │ (eu-west-1) │    │ (ap-south)  │
56//! └──────┬──────┘  └──────┬──────┘    └──────┬──────┘
57//!        │                │                   │
58//!        └────────────────┴───────────────────┘
59//!                         │ upload segments
60//!                         ▼
61//!             ┌─────────────────────┐
62//!             │   S3 / Object Store  │
63//!             │  (s3_integration)    │
64//!             └─────────────────────┘
65//! ```
66//!
67//! **Backpressure**: Workers report load via heartbeat; the coordinator
68//! withholds new assignments when a worker is saturated (see [`backpressure`]).
69//!
70//! **Circuit breaker**: Repeated worker failures trip the circuit breaker
71//! (see [`circuit_breaker`]); the coordinator stops routing jobs to that
72//! worker for a configurable cooldown period.
73//!
74//! **Geo-aware placement**: When multiple workers are available, the
75//! scheduler uses [`geo_placement::select_worker_by_region`] to prefer
76//! low-latency workers in the target region.
77
78pub mod audit_log;
79pub mod backpressure;
80pub mod checkpointing;
81pub mod circuit_breaker;
82pub mod cluster;
83pub mod compaction;
84pub mod connection_pool;
85pub mod consensus;
86pub mod coordinator;
87pub mod discovery;
88pub mod distributed_enhancements;
89pub mod fault_tolerance;
90pub mod geo_placement;
91pub mod heartbeat;
92pub mod job_dag;
93pub mod job_preemption;
94pub mod job_tracker;
95pub mod leader_election;
96pub mod lease;
97pub mod load_balancer;
98pub mod membership;
99pub mod message_bus;
100pub mod message_queue;
101pub mod metrics_aggregator;
102pub mod node_health;
103pub mod node_registry;
104pub mod node_topology;
105pub mod notifications;
106pub mod partition;
107pub mod pb;
108pub mod raft_primitives;
109pub mod replication;
110pub mod resource_quota;
111pub mod scheduler;
112pub mod segment;
113pub mod segment_merge;
114pub mod shard;
115pub mod shard_map;
116pub mod snapshot_store;
117pub mod task_distribution;
118pub mod task_priority_queue;
119pub mod task_queue;
120pub mod task_retry;
121pub mod twopc;
122pub mod weighted_round_robin;
123pub mod work_stealing;
124pub mod worker;
125pub mod worker_draining;
126
127#[cfg(feature = "s3")]
128pub mod s3_integration;
129
130#[cfg(feature = "k8s")]
131pub mod k8s_autoscale;
132
133use std::collections::HashMap;
134use std::sync::Arc;
135use std::time::{Duration, Instant};
136use thiserror::Error;
137use tokio::sync::RwLock;
138use uuid::Uuid;
139
140/// Result type for distributed operations
141pub type Result<T> = std::result::Result<T, DistributedError>;
142
143/// Errors that can occur in distributed encoding
144#[derive(Debug, Error)]
145pub enum DistributedError {
146    #[error("Worker error: {0}")]
147    Worker(String),
148
149    #[error("Coordinator error: {0}")]
150    Coordinator(String),
151
152    #[error("Job error: {0}")]
153    Job(String),
154
155    #[error("Network error: {0}")]
156    Network(#[from] tonic::transport::Error),
157
158    #[error("gRPC status error: {0}")]
159    Status(#[from] tonic::Status),
160
161    #[error("Serialization error: {0}")]
162    Serialization(#[from] serde_json::Error),
163
164    #[error("IO error: {0}")]
165    Io(#[from] std::io::Error),
166
167    #[error("Discovery error: {0}")]
168    Discovery(String),
169
170    #[error("Scheduling error: {0}")]
171    Scheduling(String),
172
173    #[error("Segmentation error: {0}")]
174    Segmentation(String),
175
176    #[error("Timeout error")]
177    Timeout,
178
179    #[error("Invalid configuration: {0}")]
180    InvalidConfig(String),
181
182    #[error("Resource exhausted: {0}")]
183    ResourceExhausted(String),
184
185    #[error("Error: {0}")]
186    Other(Box<dyn std::error::Error + Send + Sync>),
187}
188
189impl From<Box<dyn std::error::Error + Send + Sync>> for DistributedError {
190    fn from(err: Box<dyn std::error::Error + Send + Sync>) -> Self {
191        DistributedError::Other(err)
192    }
193}
194
195/// Configuration for the distributed encoder
196#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
197pub struct DistributedConfig {
198    /// Coordinator address
199    pub coordinator_addr: String,
200
201    /// Maximum number of retry attempts
202    pub max_retries: u32,
203
204    /// Heartbeat interval
205    pub heartbeat_interval: Duration,
206
207    /// Job timeout
208    pub job_timeout: Duration,
209
210    /// Maximum concurrent jobs per worker
211    pub max_concurrent_jobs: u32,
212
213    /// Enable fault tolerance
214    pub fault_tolerance: bool,
215
216    /// Worker discovery method
217    pub discovery_method: DiscoveryMethod,
218}
219
220impl Default for DistributedConfig {
221    fn default() -> Self {
222        Self {
223            coordinator_addr: "127.0.0.1:50051".to_string(),
224            max_retries: 3,
225            heartbeat_interval: Duration::from_secs(30),
226            job_timeout: Duration::from_secs(3600),
227            max_concurrent_jobs: 4,
228            fault_tolerance: true,
229            discovery_method: DiscoveryMethod::Static,
230        }
231    }
232}
233
234/// Worker discovery methods
235#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
236#[allow(dead_code)]
237pub enum DiscoveryMethod {
238    /// Static configuration
239    Static,
240    /// Multicast DNS
241    #[allow(clippy::upper_case_acronyms)]
242    MDNS,
243    /// etcd-based discovery
244    Etcd,
245    /// Consul-based discovery
246    Consul,
247}
248
249/// Job splitting strategy
250#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
251pub enum SplitStrategy {
252    /// Split by time segments
253    SegmentBased,
254    /// Split by spatial tiles
255    TileBased,
256    /// Split by GOP (Group of Pictures)
257    GopBased,
258}
259
260impl From<SplitStrategy> for i32 {
261    fn from(strategy: SplitStrategy) -> Self {
262        match strategy {
263            SplitStrategy::SegmentBased => 0,
264            SplitStrategy::TileBased => 1,
265            SplitStrategy::GopBased => 2,
266        }
267    }
268}
269
270/// Job priority levels
271#[derive(
272    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
273)]
274pub enum JobPriority {
275    Low = 0,
276    Normal = 1,
277    High = 2,
278    Critical = 3,
279}
280
281impl From<JobPriority> for u32 {
282    fn from(priority: JobPriority) -> Self {
283        priority as u32
284    }
285}
286
287/// Represents a distributed encoding job
288#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
289pub struct DistributedJob {
290    /// Unique job identifier
291    pub id: Uuid,
292
293    /// Task identifier (multiple jobs can belong to same task)
294    pub task_id: Uuid,
295
296    /// Source video URL
297    pub source_url: String,
298
299    /// Target codec
300    pub codec: String,
301
302    /// Splitting strategy
303    pub strategy: SplitStrategy,
304
305    /// Job priority
306    pub priority: JobPriority,
307
308    /// Encoding parameters
309    pub params: EncodingParams,
310
311    /// Output destination
312    pub output_url: String,
313
314    /// Deadline timestamp (Unix epoch)
315    pub deadline: Option<i64>,
316}
317
318/// Encoding parameters
319#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
320pub struct EncodingParams {
321    pub bitrate: Option<u32>,
322    pub width: Option<u32>,
323    pub height: Option<u32>,
324    pub preset: Option<String>,
325    pub profile: Option<String>,
326    pub crf: Option<u32>,
327    pub extra_params: std::collections::HashMap<String, String>,
328}
329
330impl Default for EncodingParams {
331    fn default() -> Self {
332        Self {
333            bitrate: None,
334            width: None,
335            height: None,
336            preset: Some("medium".to_string()),
337            profile: None,
338            crf: Some(23),
339            extra_params: std::collections::HashMap::new(),
340        }
341    }
342}
343
344/// Internal record for a submitted job, tracking its lifecycle.
345#[derive(Debug, Clone)]
346struct JobRecord {
347    /// The original job definition.
348    #[allow(dead_code)]
349    job: DistributedJob,
350    /// Current status.
351    status: JobStatus,
352    /// When the job was submitted.
353    submitted_at: Instant,
354    /// Number of retry attempts so far.
355    retries: u32,
356}
357
358/// Main distributed encoder interface.
359///
360/// Maintains an in-process job store so that `submit_job`, `job_status`, and
361/// `cancel_job` operate on real state. In a production deployment the store
362/// would be backed by the gRPC coordinator; this implementation provides a
363/// fully functional local fallback that exercises the complete lifecycle.
364///
365/// When `config.coordinator_addr` is non-empty, a background coordinator
366/// server is started on that address so workers can connect and register.
367pub struct DistributedEncoder {
368    config: DistributedConfig,
369    /// Job store keyed by job UUID.
370    jobs: Arc<RwLock<HashMap<Uuid, JobRecord>>>,
371    /// Background coordinator server task handle, present when a server was
372    /// successfully started. Aborted on drop.
373    server_handle: Option<tokio::task::JoinHandle<()>>,
374}
375
376impl DistributedEncoder {
377    /// Create a new distributed encoder with the given configuration.
378    ///
379    /// If `config.coordinator_addr` is non-empty and parses as a valid
380    /// [`std::net::SocketAddr`], a background [`coordinator::Coordinator`]
381    /// server is spawned on that address so remote workers can connect.
382    /// Failures to bind are logged as warnings and degrade gracefully —
383    /// the local job store remains fully functional regardless.
384    #[must_use]
385    pub fn new(config: DistributedConfig) -> Self {
386        let server_handle = if !config.coordinator_addr.is_empty() {
387            match config.coordinator_addr.parse::<std::net::SocketAddr>() {
388                Ok(addr) => {
389                    let coord = crate::coordinator::Coordinator::new(
390                        crate::coordinator::CoordinatorConfig::default(),
391                    );
392                    let handle = tokio::spawn(async move {
393                        if let Err(e) = coord.serve(addr).await {
394                            tracing::warn!("Coordinator server stopped on {}: {}", addr, e);
395                        }
396                    });
397                    Some(handle)
398                }
399                Err(e) => {
400                    tracing::warn!(
401                        "Invalid coordinator_addr '{}': {}",
402                        config.coordinator_addr,
403                        e
404                    );
405                    None
406                }
407            }
408        } else {
409            None
410        };
411
412        Self {
413            config,
414            jobs: Arc::new(RwLock::new(HashMap::new())),
415            server_handle,
416        }
417    }
418
419    /// Create a new distributed encoder with default configuration
420    #[must_use]
421    pub fn with_defaults() -> Self {
422        Self::new(DistributedConfig::default())
423    }
424
425    /// Get the current configuration
426    #[must_use]
427    pub fn config(&self) -> &DistributedConfig {
428        &self.config
429    }
430
431    /// Return the number of currently tracked jobs.
432    pub async fn job_count(&self) -> usize {
433        self.jobs.read().await.len()
434    }
435
436    /// Return the number of active (non-terminal) jobs.
437    pub async fn active_job_count(&self) -> usize {
438        self.jobs
439            .read()
440            .await
441            .values()
442            .filter(|r| {
443                matches!(
444                    r.status,
445                    JobStatus::Pending | JobStatus::Assigned | JobStatus::InProgress
446                )
447            })
448            .count()
449    }
450
451    /// Submit a job for distributed encoding.
452    ///
453    /// Validates the job, checks concurrency limits, registers it in the
454    /// internal store, and returns the job ID on success.
455    ///
456    /// # Arguments
457    ///
458    /// * `job` - The encoding job to submit
459    ///
460    /// # Returns
461    ///
462    /// Returns the job ID on success
463    ///
464    /// # Errors
465    ///
466    /// Returns `DistributedError::InvalidConfig` if the job definition is
467    /// invalid, or `DistributedError::ResourceExhausted` if the maximum
468    /// concurrent job limit has been reached.
469    pub async fn submit_job(&self, job: DistributedJob) -> Result<Uuid> {
470        // --- validation ---
471        if job.source_url.is_empty() {
472            return Err(DistributedError::InvalidConfig(
473                "source_url must not be empty".to_string(),
474            ));
475        }
476        if job.output_url.is_empty() {
477            return Err(DistributedError::InvalidConfig(
478                "output_url must not be empty".to_string(),
479            ));
480        }
481        if job.codec.is_empty() {
482            return Err(DistributedError::InvalidConfig(
483                "codec must not be empty".to_string(),
484            ));
485        }
486
487        // Check deadline is not already in the past
488        if let Some(deadline) = job.deadline {
489            let now = std::time::SystemTime::now()
490                .duration_since(std::time::UNIX_EPOCH)
491                .map_err(|e| DistributedError::Job(format!("System time error: {e}")))?;
492            if deadline < now.as_secs() as i64 {
493                return Err(DistributedError::Job(
494                    "Job deadline is already in the past".to_string(),
495                ));
496            }
497        }
498
499        let mut jobs = self.jobs.write().await;
500
501        // Check for duplicate job ID
502        if jobs.contains_key(&job.id) {
503            return Err(DistributedError::Job(format!(
504                "Job with ID {} already exists",
505                job.id
506            )));
507        }
508
509        // Enforce concurrency limit
510        let active_count = jobs
511            .values()
512            .filter(|r| {
513                matches!(
514                    r.status,
515                    JobStatus::Pending | JobStatus::Assigned | JobStatus::InProgress
516                )
517            })
518            .count();
519
520        if active_count >= self.config.max_concurrent_jobs as usize {
521            return Err(DistributedError::ResourceExhausted(format!(
522                "Maximum concurrent jobs ({}) reached",
523                self.config.max_concurrent_jobs
524            )));
525        }
526
527        let job_id = job.id;
528
529        tracing::info!(
530            "Submitting job {} (codec={}, strategy={:?}, priority={:?}) to coordinator at {}",
531            job_id,
532            job.codec,
533            job.strategy,
534            job.priority,
535            self.config.coordinator_addr
536        );
537
538        jobs.insert(
539            job_id,
540            JobRecord {
541                job,
542                status: JobStatus::Pending,
543                submitted_at: Instant::now(),
544                retries: 0,
545            },
546        );
547
548        Ok(job_id)
549    }
550
551    /// Query the status of a previously submitted job.
552    ///
553    /// In addition to returning the stored status, this method performs
554    /// timeout checking: if a job has been active longer than the configured
555    /// `job_timeout` it is automatically marked as `Failed`.
556    ///
557    /// # Errors
558    ///
559    /// Returns `DistributedError::Job` if the job ID is not found.
560    pub async fn job_status(&self, job_id: Uuid) -> Result<JobStatus> {
561        tracing::debug!("Querying status for job {}", job_id);
562
563        let mut jobs = self.jobs.write().await;
564        let record = jobs
565            .get_mut(&job_id)
566            .ok_or_else(|| DistributedError::Job(format!("Job {job_id} not found")))?;
567
568        // Check for timeout on active jobs
569        if matches!(
570            record.status,
571            JobStatus::Pending | JobStatus::Assigned | JobStatus::InProgress
572        ) && record.submitted_at.elapsed() > self.config.job_timeout
573        {
574            tracing::warn!(
575                "Job {} has timed out after {:?}",
576                job_id,
577                self.config.job_timeout
578            );
579            record.status = JobStatus::Failed;
580        }
581
582        Ok(record.status)
583    }
584
585    /// Cancel a previously submitted job.
586    ///
587    /// Only jobs that are not yet in a terminal state (`Completed`, `Failed`,
588    /// `Cancelled`) can be cancelled.
589    ///
590    /// # Errors
591    ///
592    /// Returns `DistributedError::Job` if the job ID is not found or the job
593    /// is already in a terminal state.
594    pub async fn cancel_job(&self, job_id: Uuid) -> Result<()> {
595        tracing::info!("Cancelling job {}", job_id);
596
597        let mut jobs = self.jobs.write().await;
598        let record = jobs
599            .get_mut(&job_id)
600            .ok_or_else(|| DistributedError::Job(format!("Job {job_id} not found")))?;
601
602        match record.status {
603            JobStatus::Completed => {
604                return Err(DistributedError::Job(format!(
605                    "Job {job_id} is already completed and cannot be cancelled"
606                )));
607            }
608            JobStatus::Failed => {
609                return Err(DistributedError::Job(format!(
610                    "Job {job_id} has already failed and cannot be cancelled"
611                )));
612            }
613            JobStatus::Cancelled => {
614                return Err(DistributedError::Job(format!(
615                    "Job {job_id} is already cancelled"
616                )));
617            }
618            _ => {}
619        }
620
621        record.status = JobStatus::Cancelled;
622        Ok(())
623    }
624
625    /// Advance a job to the next logical status (for internal/testing use).
626    ///
627    /// Transitions: Pending -> Assigned -> InProgress -> Completed
628    ///
629    /// # Errors
630    ///
631    /// Returns error if the job is not found or is in a terminal state.
632    pub async fn advance_job(&self, job_id: Uuid) -> Result<JobStatus> {
633        let mut jobs = self.jobs.write().await;
634        let record = jobs
635            .get_mut(&job_id)
636            .ok_or_else(|| DistributedError::Job(format!("Job {job_id} not found")))?;
637
638        record.status = match record.status {
639            JobStatus::Pending => JobStatus::Assigned,
640            JobStatus::Assigned => JobStatus::InProgress,
641            JobStatus::InProgress => JobStatus::Completed,
642            other => {
643                return Err(DistributedError::Job(format!(
644                    "Cannot advance job in terminal state: {other:?}"
645                )));
646            }
647        };
648
649        Ok(record.status)
650    }
651
652    /// Mark a job as failed (for internal/testing use).
653    ///
654    /// # Errors
655    ///
656    /// Returns error if the job is not found or already in a terminal state.
657    pub async fn fail_job(&self, job_id: Uuid) -> Result<()> {
658        let mut jobs = self.jobs.write().await;
659        let record = jobs
660            .get_mut(&job_id)
661            .ok_or_else(|| DistributedError::Job(format!("Job {job_id} not found")))?;
662
663        if matches!(record.status, JobStatus::Completed | JobStatus::Cancelled) {
664            return Err(DistributedError::Job(format!(
665                "Cannot fail job {job_id} in terminal state: {:?}",
666                record.status
667            )));
668        }
669
670        // Check if we should retry
671        if self.config.fault_tolerance && record.retries < self.config.max_retries {
672            record.retries += 1;
673            record.status = JobStatus::Pending;
674            tracing::info!(
675                "Retrying job {} (attempt {}/{})",
676                job_id,
677                record.retries,
678                self.config.max_retries
679            );
680        } else {
681            record.status = JobStatus::Failed;
682        }
683
684        Ok(())
685    }
686
687    /// Get the retry count for a job.
688    ///
689    /// # Errors
690    ///
691    /// Returns error if the job is not found.
692    pub async fn job_retries(&self, job_id: Uuid) -> Result<u32> {
693        let jobs = self.jobs.read().await;
694        let record = jobs
695            .get(&job_id)
696            .ok_or_else(|| DistributedError::Job(format!("Job {job_id} not found")))?;
697        Ok(record.retries)
698    }
699
700    /// List all job IDs with their current statuses.
701    pub async fn list_jobs(&self) -> Vec<(Uuid, JobStatus)> {
702        self.jobs
703            .read()
704            .await
705            .iter()
706            .map(|(id, record)| (*id, record.status))
707            .collect()
708    }
709
710    /// Submit a batch of jobs atomically.
711    ///
712    /// Attempts to submit each job in `jobs` in order.  Returns a parallel
713    /// `Vec` of `Result<Uuid>` — one entry per submitted job.  Jobs that fail
714    /// validation or exceed the concurrency limit produce `Err` entries;
715    /// successful jobs produce `Ok(job_id)`.
716    ///
717    /// The batch is *not* transactional: successful jobs submitted earlier in
718    /// the slice are committed even if a later job fails.
719    pub async fn submit_jobs_batch(&self, jobs: Vec<DistributedJob>) -> Vec<Result<Uuid>> {
720        let mut results = Vec::with_capacity(jobs.len());
721        for job in jobs {
722            results.push(self.submit_job(job).await);
723        }
724        results
725    }
726}
727
728impl Drop for DistributedEncoder {
729    fn drop(&mut self) {
730        if let Some(handle) = self.server_handle.take() {
731            handle.abort();
732        }
733    }
734}
735
736/// Job execution status
737#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
738pub enum JobStatus {
739    Pending,
740    Assigned,
741    InProgress,
742    Completed,
743    Failed,
744    Cancelled,
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    fn make_job() -> DistributedJob {
752        DistributedJob {
753            id: Uuid::new_v4(),
754            task_id: Uuid::new_v4(),
755            source_url: "s3://bucket/input.mp4".to_string(),
756            codec: "av1".to_string(),
757            strategy: SplitStrategy::SegmentBased,
758            priority: JobPriority::Normal,
759            params: EncodingParams::default(),
760            output_url: "s3://bucket/output.mp4".to_string(),
761            deadline: None,
762        }
763    }
764
765    #[test]
766    fn test_default_config() {
767        let config = DistributedConfig::default();
768        assert_eq!(config.coordinator_addr, "127.0.0.1:50051");
769        assert_eq!(config.max_retries, 3);
770        assert_eq!(config.max_concurrent_jobs, 4);
771    }
772
773    #[tokio::test]
774    async fn test_encoder_creation() {
775        let encoder = DistributedEncoder::with_defaults();
776        assert_eq!(encoder.config().coordinator_addr, "127.0.0.1:50051");
777    }
778
779    #[test]
780    fn test_job_priority_ordering() {
781        assert!(JobPriority::Critical > JobPriority::High);
782        assert!(JobPriority::High > JobPriority::Normal);
783        assert!(JobPriority::Normal > JobPriority::Low);
784    }
785
786    #[tokio::test]
787    async fn test_submit_and_query_job() {
788        let encoder = DistributedEncoder::with_defaults();
789        let job = make_job();
790        let job_id = job.id;
791
792        let returned_id = encoder
793            .submit_job(job)
794            .await
795            .expect("submit should succeed");
796        assert_eq!(returned_id, job_id);
797
798        let status = encoder
799            .job_status(job_id)
800            .await
801            .expect("status should succeed");
802        assert_eq!(status, JobStatus::Pending);
803    }
804
805    #[tokio::test]
806    async fn test_submit_rejects_empty_source_url() {
807        let encoder = DistributedEncoder::with_defaults();
808        let mut job = make_job();
809        job.source_url = String::new();
810
811        let result = encoder.submit_job(job).await;
812        assert!(result.is_err());
813    }
814
815    #[tokio::test]
816    async fn test_submit_rejects_empty_codec() {
817        let encoder = DistributedEncoder::with_defaults();
818        let mut job = make_job();
819        job.codec = String::new();
820
821        let result = encoder.submit_job(job).await;
822        assert!(result.is_err());
823    }
824
825    #[tokio::test]
826    async fn test_submit_rejects_empty_output_url() {
827        let encoder = DistributedEncoder::with_defaults();
828        let mut job = make_job();
829        job.output_url = String::new();
830
831        let result = encoder.submit_job(job).await;
832        assert!(result.is_err());
833    }
834
835    #[tokio::test]
836    async fn test_submit_rejects_duplicate_id() {
837        let encoder = DistributedEncoder::with_defaults();
838        let job = make_job();
839        let dup = job.clone();
840
841        encoder
842            .submit_job(job)
843            .await
844            .expect("first submit should succeed");
845        let result = encoder.submit_job(dup).await;
846        assert!(result.is_err());
847    }
848
849    #[tokio::test]
850    async fn test_cancel_job() {
851        let encoder = DistributedEncoder::with_defaults();
852        let job = make_job();
853        let job_id = job.id;
854
855        encoder
856            .submit_job(job)
857            .await
858            .expect("submit should succeed");
859        encoder
860            .cancel_job(job_id)
861            .await
862            .expect("cancel should succeed");
863
864        let status = encoder
865            .job_status(job_id)
866            .await
867            .expect("status should succeed");
868        assert_eq!(status, JobStatus::Cancelled);
869    }
870
871    #[tokio::test]
872    async fn test_cancel_nonexistent_job_fails() {
873        let encoder = DistributedEncoder::with_defaults();
874        let result = encoder.cancel_job(Uuid::new_v4()).await;
875        assert!(result.is_err());
876    }
877
878    #[tokio::test]
879    async fn test_cancel_completed_job_fails() {
880        let encoder = DistributedEncoder::with_defaults();
881        let job = make_job();
882        let job_id = job.id;
883
884        encoder
885            .submit_job(job)
886            .await
887            .expect("submit should succeed");
888        // Advance to Completed
889        encoder
890            .advance_job(job_id)
891            .await
892            .expect("advance should succeed"); // Assigned
893        encoder
894            .advance_job(job_id)
895            .await
896            .expect("advance should succeed"); // InProgress
897        encoder
898            .advance_job(job_id)
899            .await
900            .expect("advance should succeed"); // Completed
901
902        let result = encoder.cancel_job(job_id).await;
903        assert!(result.is_err());
904    }
905
906    #[tokio::test]
907    async fn test_advance_job_lifecycle() {
908        let encoder = DistributedEncoder::with_defaults();
909        let job = make_job();
910        let job_id = job.id;
911
912        encoder
913            .submit_job(job)
914            .await
915            .expect("submit should succeed");
916
917        let s1 = encoder
918            .advance_job(job_id)
919            .await
920            .expect("advance should succeed");
921        assert_eq!(s1, JobStatus::Assigned);
922
923        let s2 = encoder
924            .advance_job(job_id)
925            .await
926            .expect("advance should succeed");
927        assert_eq!(s2, JobStatus::InProgress);
928
929        let s3 = encoder
930            .advance_job(job_id)
931            .await
932            .expect("advance should succeed");
933        assert_eq!(s3, JobStatus::Completed);
934
935        // Cannot advance past Completed
936        let result = encoder.advance_job(job_id).await;
937        assert!(result.is_err());
938    }
939
940    #[tokio::test]
941    async fn test_fail_job_with_retry() {
942        let config = DistributedConfig {
943            max_retries: 2,
944            fault_tolerance: true,
945            ..DistributedConfig::default()
946        };
947        let encoder = DistributedEncoder::new(config);
948        let job = make_job();
949        let job_id = job.id;
950
951        encoder
952            .submit_job(job)
953            .await
954            .expect("submit should succeed");
955
956        // First failure: should retry (back to Pending)
957        encoder.fail_job(job_id).await.expect("fail should succeed");
958        let status = encoder
959            .job_status(job_id)
960            .await
961            .expect("status should succeed");
962        assert_eq!(status, JobStatus::Pending);
963        let retries = encoder
964            .job_retries(job_id)
965            .await
966            .expect("retries should succeed");
967        assert_eq!(retries, 1);
968
969        // Second failure: should retry again
970        encoder.fail_job(job_id).await.expect("fail should succeed");
971        let retries = encoder
972            .job_retries(job_id)
973            .await
974            .expect("retries should succeed");
975        assert_eq!(retries, 2);
976
977        // Third failure: max retries exhausted, should be Failed
978        encoder.fail_job(job_id).await.expect("fail should succeed");
979        let status = encoder
980            .job_status(job_id)
981            .await
982            .expect("status should succeed");
983        assert_eq!(status, JobStatus::Failed);
984    }
985
986    #[tokio::test]
987    async fn test_fail_without_fault_tolerance() {
988        let config = DistributedConfig {
989            fault_tolerance: false,
990            ..DistributedConfig::default()
991        };
992        let encoder = DistributedEncoder::new(config);
993        let job = make_job();
994        let job_id = job.id;
995
996        encoder
997            .submit_job(job)
998            .await
999            .expect("submit should succeed");
1000        encoder.fail_job(job_id).await.expect("fail should succeed");
1001
1002        let status = encoder
1003            .job_status(job_id)
1004            .await
1005            .expect("status should succeed");
1006        assert_eq!(status, JobStatus::Failed);
1007    }
1008
1009    #[tokio::test]
1010    async fn test_concurrency_limit() {
1011        let config = DistributedConfig {
1012            max_concurrent_jobs: 2,
1013            ..DistributedConfig::default()
1014        };
1015        let encoder = DistributedEncoder::new(config);
1016
1017        encoder
1018            .submit_job(make_job())
1019            .await
1020            .expect("first should succeed");
1021        encoder
1022            .submit_job(make_job())
1023            .await
1024            .expect("second should succeed");
1025
1026        // Third should be rejected
1027        let result = encoder.submit_job(make_job()).await;
1028        assert!(result.is_err());
1029    }
1030
1031    #[tokio::test]
1032    async fn test_concurrency_freed_after_cancel() {
1033        let config = DistributedConfig {
1034            max_concurrent_jobs: 1,
1035            ..DistributedConfig::default()
1036        };
1037        let encoder = DistributedEncoder::new(config);
1038
1039        let job = make_job();
1040        let job_id = job.id;
1041        encoder.submit_job(job).await.expect("first should succeed");
1042
1043        // Cannot submit another
1044        assert!(encoder.submit_job(make_job()).await.is_err());
1045
1046        // Cancel the first
1047        encoder
1048            .cancel_job(job_id)
1049            .await
1050            .expect("cancel should succeed");
1051
1052        // Now we can submit
1053        encoder
1054            .submit_job(make_job())
1055            .await
1056            .expect("after cancel should succeed");
1057    }
1058
1059    #[tokio::test]
1060    async fn test_list_jobs() {
1061        let encoder = DistributedEncoder::with_defaults();
1062        let j1 = make_job();
1063        let j2 = make_job();
1064        let id1 = j1.id;
1065        let id2 = j2.id;
1066
1067        encoder.submit_job(j1).await.expect("submit should succeed");
1068        encoder.submit_job(j2).await.expect("submit should succeed");
1069
1070        let jobs = encoder.list_jobs().await;
1071        assert_eq!(jobs.len(), 2);
1072
1073        let ids: Vec<Uuid> = jobs.iter().map(|(id, _)| *id).collect();
1074        assert!(ids.contains(&id1));
1075        assert!(ids.contains(&id2));
1076    }
1077
1078    #[tokio::test]
1079    async fn test_job_count() {
1080        let encoder = DistributedEncoder::with_defaults();
1081        assert_eq!(encoder.job_count().await, 0);
1082
1083        encoder
1084            .submit_job(make_job())
1085            .await
1086            .expect("submit should succeed");
1087        assert_eq!(encoder.job_count().await, 1);
1088        assert_eq!(encoder.active_job_count().await, 1);
1089    }
1090
1091    #[tokio::test]
1092    async fn test_status_nonexistent_job_fails() {
1093        let encoder = DistributedEncoder::with_defaults();
1094        let result = encoder.job_status(Uuid::new_v4()).await;
1095        assert!(result.is_err());
1096    }
1097
1098    #[tokio::test]
1099    async fn test_job_timeout_detection() {
1100        let config = DistributedConfig {
1101            job_timeout: Duration::from_millis(1),
1102            ..DistributedConfig::default()
1103        };
1104        let encoder = DistributedEncoder::new(config);
1105        let job = make_job();
1106        let job_id = job.id;
1107
1108        encoder
1109            .submit_job(job)
1110            .await
1111            .expect("submit should succeed");
1112
1113        // Wait briefly for timeout
1114        tokio::time::sleep(Duration::from_millis(10)).await;
1115
1116        let status = encoder
1117            .job_status(job_id)
1118            .await
1119            .expect("status should succeed");
1120        assert_eq!(status, JobStatus::Failed);
1121    }
1122
1123    #[tokio::test]
1124    async fn test_submit_past_deadline_rejected() {
1125        let encoder = DistributedEncoder::with_defaults();
1126        let mut job = make_job();
1127        job.deadline = Some(0); // epoch = far in the past
1128
1129        let result = encoder.submit_job(job).await;
1130        assert!(result.is_err());
1131    }
1132
1133    #[tokio::test]
1134    async fn test_coordinator_server_starts() {
1135        // Bind an ephemeral port to discover a free port, then release it so
1136        // the coordinator can bind to it.
1137        let port = {
1138            let l = std::net::TcpListener::bind("127.0.0.1:0")
1139                .expect("ephemeral port allocation must succeed");
1140            l.local_addr().expect("local_addr must work").port()
1141        };
1142        let addr = format!("127.0.0.1:{port}");
1143        let config = DistributedConfig {
1144            coordinator_addr: addr.clone(),
1145            ..DistributedConfig::default()
1146        };
1147        let encoder = DistributedEncoder::new(config);
1148        // Give the background task a moment to start the TCP listener.
1149        tokio::time::sleep(std::time::Duration::from_millis(80)).await;
1150        // server_handle should be set when the addr parses successfully.
1151        assert!(
1152            encoder.server_handle.is_some(),
1153            "server_handle should be set for a valid coordinator_addr"
1154        );
1155        // Verify the listener is actually accepting connections on that port.
1156        let connect_result = tokio::net::TcpStream::connect(addr.as_str()).await;
1157        assert!(
1158            connect_result.is_ok(),
1159            "coordinator TCP control server should be accepting connections on {addr}"
1160        );
1161        // Drop must not panic.
1162    }
1163
1164    #[tokio::test]
1165    async fn test_distributed_encoder_local_fallback_still_works() {
1166        // Port 1 is privileged and will fail to bind, exercising the graceful
1167        // failure path.  Privileged-port bind may or may not fail depending on
1168        // the OS; either way the local job store must remain functional.
1169        let config = DistributedConfig {
1170            coordinator_addr: "127.0.0.1:1".to_string(),
1171            ..DistributedConfig::default()
1172        };
1173        let encoder = DistributedEncoder::new(config);
1174        // Allow any server-spawn attempt to settle.
1175        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1176
1177        // Local job store must work regardless of gRPC server outcome.
1178        let job = make_job();
1179        let result = encoder.submit_job(job).await;
1180        assert!(
1181            result.is_ok(),
1182            "local job store must work even when gRPC server bind may fail"
1183        );
1184    }
1185}