Add an in-depth tutorial covering the engineering fundamentals behind building a job execution system like Windmill: - Part 1: Finite state machine foundation with type-safe job status transitions - Part 2: PostgreSQL persistence using SQLx with advanced techniques like SKIP LOCKED - Part 3: Zombie job detection, error recovery patterns, and circuit breakers - Part 4: Real-time log streaming with Server-Sent Events and WebSocket implementation - Part 5: Complete system integration with testing and scaling insights The tutorial emphasizes practical engineering decisions, performance considerations, and distributed systems patterns. Includes extensive Rust code examples and explanations of trade-offs. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
30 KiB
Building a Mini Windmill: A Deep Dive into Job Execution Systems
Introduction
Welcome to this comprehensive tutorial where we'll build a simplified version of Windmill's job execution system from scratch. We'll start with a basic finite state machine and progressively add sophisticated features like persistence, error handling, and real-time monitoring.
By the end of this tutorial, you'll understand the core engineering decisions behind distributed job execution systems and have built your own mini-windmill!
Part 1: The Foundation - A Finite State Machine
Why Start with a State Machine?
Job execution is fundamentally about state transitions. A job starts in one state (Queued), moves through various states (Running, Completed, Failed), and these transitions must be predictable and atomic.
Let's define our core job states:
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum JobStatus {
Queued,
Running,
Completed,
Failed,
Cancelled,
}
#[derive(Debug, Clone)]
pub struct Job {
pub id: Uuid,
pub script_content: String,
pub status: JobStatus,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub result: Option<serde_json::Value>,
pub error: Option<String>,
}
The State Machine Engine
Our state machine needs to enforce valid transitions and handle side effects:
use std::collections::HashMap;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use serde::{Serialize, Deserialize};
use tokio::sync::{RwLock, mpsc};
use std::sync::Arc;
pub struct JobStateMachine {
jobs: Arc<RwLock<HashMap<Uuid, Job>>>,
event_sender: mpsc::UnboundedSender<JobEvent>,
}
#[derive(Debug, Clone)]
pub enum JobEvent {
Created(Uuid),
Started(Uuid),
Completed(Uuid, serde_json::Value),
Failed(Uuid, String),
Cancelled(Uuid),
}
impl JobStateMachine {
pub fn new() -> (Self, mpsc::UnboundedReceiver<JobEvent>) {
let (tx, rx) = mpsc::unbounded_channel();
(
Self {
jobs: Arc::new(RwLock::new(HashMap::new())),
event_sender: tx,
},
rx,
)
}
pub async fn create_job(&self, script_content: String) -> Result<Uuid, JobError> {
let job_id = Uuid::new_v4();
let job = Job {
id: job_id,
script_content,
status: JobStatus::Queued,
created_at: Utc::now(),
started_at: None,
completed_at: None,
result: None,
error: None,
};
{
let mut jobs = self.jobs.write().await;
jobs.insert(job_id, job);
}
self.event_sender.send(JobEvent::Created(job_id))?;
Ok(job_id)
}
pub async fn transition_to_running(&self, job_id: Uuid) -> Result<(), JobError> {
let mut jobs = self.jobs.write().await;
let job = jobs.get_mut(&job_id).ok_or(JobError::NotFound)?;
match job.status {
JobStatus::Queued => {
job.status = JobStatus::Running;
job.started_at = Some(Utc::now());
self.event_sender.send(JobEvent::Started(job_id))?;
Ok(())
}
_ => Err(JobError::InvalidTransition {
from: job.status.clone(),
to: JobStatus::Running,
}),
}
}
// Similar methods for other transitions...
}
#[derive(Debug, thiserror::Error)]
pub enum JobError {
#[error("Job not found")]
NotFound,
#[error("Invalid transition from {from:?} to {to:?}")]
InvalidTransition { from: JobStatus, to: JobStatus },
#[error("Event channel error: {0}")]
ChannelError(#[from] mpsc::error::SendError<JobEvent>),
}
Why This Design?
- Immutable Transitions: Once we validate a state transition, it's committed atomically
- Event-Driven: Every state change emits an event, enabling observability
- Type Safety: Invalid transitions are caught at compile time where possible
- Concurrent Access: RwLock allows multiple readers but exclusive writers
Testing Our State Machine
#[tokio::test]
async fn test_job_lifecycle() {
let (state_machine, mut events) = JobStateMachine::new();
// Create a job
let job_id = state_machine.create_job("print('hello')".to_string()).await.unwrap();
assert_eq!(events.recv().await.unwrap(), JobEvent::Created(job_id));
// Start the job
state_machine.transition_to_running(job_id).await.unwrap();
assert_eq!(events.recv().await.unwrap(), JobEvent::Started(job_id));
// Complete the job
let result = serde_json::json!({"output": "hello"});
state_machine.complete_job(job_id, result.clone()).await.unwrap();
let job = state_machine.get_job(job_id).await.unwrap();
assert_eq!(job.status, JobStatus::Completed);
assert_eq!(job.result, Some(result));
}
Part 2: Adding Persistence with PostgreSQL and SQLx
Why PostgreSQL?
While our in-memory state machine is fast, it's not durable. We need persistence for:
- Crash Recovery: Jobs survive system restarts
- Scalability: Multiple workers can access the same job queue
- Audit Trail: Complete history of job executions
- Complex Queries: Analytics and monitoring
Database Schema Design
-- Our job table with careful indexing
CREATE TABLE jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
script_content TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'queued',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
result JSONB,
error TEXT,
-- Performance indexes
CONSTRAINT valid_status CHECK (status IN ('queued', 'running', 'completed', 'failed', 'cancelled'))
);
-- Critical indexes for performance
CREATE INDEX idx_jobs_status_created_at ON jobs (status, created_at);
CREATE INDEX idx_jobs_created_at ON jobs (created_at DESC);
-- Job logs for streaming
CREATE TABLE job_logs (
id BIGSERIAL PRIMARY KEY,
job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
level VARCHAR(10) NOT NULL DEFAULT 'info',
message TEXT NOT NULL
);
CREATE INDEX idx_job_logs_job_id_timestamp ON job_logs (job_id, timestamp);
Persistent Job Store
use sqlx::{PgPool, Row};
use sqlx::postgres::PgRow;
#[derive(Clone)]
pub struct PersistentJobStore {
pool: PgPool,
event_sender: mpsc::UnboundedSender<JobEvent>,
}
impl PersistentJobStore {
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
let pool = PgPool::connect(database_url).await?;
// Run migrations
sqlx::migrate!("./migrations").run(&pool).await?;
let (event_sender, _) = mpsc::unbounded_channel();
Ok(Self {
pool,
event_sender,
})
}
pub async fn create_job(&self, script_content: &str) -> Result<Uuid, JobError> {
let job_id = Uuid::new_v4();
sqlx::query!(
r#"
INSERT INTO jobs (id, script_content, status, created_at)
VALUES ($1, $2, 'queued', NOW())
"#,
job_id,
script_content
)
.execute(&self.pool)
.await?;
self.event_sender.send(JobEvent::Created(job_id))?;
Ok(job_id)
}
pub async fn transition_to_running(&self, job_id: Uuid) -> Result<(), JobError> {
// Critical: Use a transaction to ensure atomicity
let mut tx = self.pool.begin().await?;
// First, check current status
let current_status = sqlx::query_scalar!(
"SELECT status FROM jobs WHERE id = $1 FOR UPDATE",
job_id
)
.fetch_optional(&mut *tx)
.await?
.ok_or(JobError::NotFound)?;
if current_status != "queued" {
return Err(JobError::InvalidTransition {
from: current_status.parse().unwrap(),
to: JobStatus::Running,
});
}
// Update to running
sqlx::query!(
r#"
UPDATE jobs
SET status = 'running', started_at = NOW()
WHERE id = $1
"#,
job_id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
self.event_sender.send(JobEvent::Started(job_id))?;
Ok(())
}
pub async fn get_next_queued_job(&self) -> Result<Option<Job>, JobError> {
// SKIP LOCKED prevents workers from competing for the same job
let row = sqlx::query!(
r#"
UPDATE jobs
SET status = 'running', started_at = NOW()
WHERE id = (
SELECT id FROM jobs
WHERE status = 'queued'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, script_content, status, created_at, started_at, completed_at, result, error
"#
)
.fetch_optional(&self.pool)
.await?;
match row {
Some(row) => {
let job = Job {
id: row.id,
script_content: row.script_content,
status: row.status.parse().unwrap(),
created_at: row.created_at,
started_at: row.started_at,
completed_at: row.completed_at,
result: row.result,
error: row.error,
};
self.event_sender.send(JobEvent::Started(job.id))?;
Ok(Some(job))
}
None => Ok(None),
}
}
}
The Power of FOR UPDATE SKIP LOCKED
This PostgreSQL feature is crucial for distributed systems:
- FOR UPDATE: Locks the row for modification
- SKIP LOCKED: If a row is already locked, skip it instead of waiting
- Result: Multiple workers can poll for jobs without blocking each other
Part 3: Zombie Jobs and Error Handling
What Are Zombie Jobs?
Zombie jobs are jobs that are marked as "running" but their worker has crashed or disappeared. Without detection and recovery, these jobs would remain in limbo forever.
Heartbeat-Based Detection
use std::time::Duration;
#[derive(Clone)]
pub struct ZombieJobDetector {
store: PersistentJobStore,
heartbeat_timeout: Duration,
}
impl ZombieJobDetector {
pub fn new(store: PersistentJobStore) -> Self {
Self {
store,
heartbeat_timeout: Duration::from_secs(30), // 30 second timeout
}
}
pub async fn start_heartbeat_monitoring(&self) {
let mut interval = tokio::time::interval(Duration::from_secs(10));
loop {
interval.tick().await;
if let Err(e) = self.detect_and_recover_zombies().await {
tracing::error!("Failed to detect zombies: {}", e);
}
}
}
async fn detect_and_recover_zombies(&self) -> Result<(), JobError> {
let timeout_threshold = Utc::now() - chrono::Duration::seconds(self.heartbeat_timeout.as_secs() as i64);
// Find jobs that have been running too long without updates
let zombie_jobs = sqlx::query!(
r#"
SELECT id FROM jobs
WHERE status = 'running'
AND started_at < $1
"#,
timeout_threshold
)
.fetch_all(&self.store.pool)
.await?;
for job in zombie_jobs {
tracing::warn!("Detected zombie job: {}", job.id);
self.recover_zombie_job(job.id).await?;
}
Ok(())
}
async fn recover_zombie_job(&self, job_id: Uuid) -> Result<(), JobError> {
// Strategy 1: Retry the job (move back to queued)
// Strategy 2: Mark as failed
// Strategy 3: Send to dead letter queue
sqlx::query!(
r#"
UPDATE jobs
SET status = 'queued', started_at = NULL, error = 'Recovered from zombie state'
WHERE id = $1 AND status = 'running'
"#,
job_id
)
.execute(&self.store.pool)
.await?;
tracing::info!("Recovered zombie job: {}", job_id);
Ok(())
}
}
Advanced Error Recovery Strategies
pub struct ErrorRecoveryManager {
store: PersistentJobStore,
retry_config: RetryConfig,
}
#[derive(Clone)]
pub struct RetryConfig {
pub max_retries: u32,
pub backoff_multiplier: f64,
pub base_delay: Duration,
}
impl ErrorRecoveryManager {
pub async fn handle_job_failure(&self, job_id: Uuid, error: String) -> Result<(), JobError> {
let mut tx = self.store.pool.begin().await?;
// Get current retry count
let retry_count = sqlx::query_scalar!(
"SELECT COALESCE((result->>'retry_count')::int, 0) FROM jobs WHERE id = $1",
job_id
)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(0);
if retry_count < self.retry_config.max_retries as i32 {
// Calculate exponential backoff delay
let delay = self.retry_config.base_delay.as_secs() as f64
* self.retry_config.backoff_multiplier.powi(retry_count);
let retry_at = Utc::now() + chrono::Duration::seconds(delay as i64);
// Schedule retry
sqlx::query!(
r#"
UPDATE jobs
SET status = 'queued',
started_at = NULL,
result = jsonb_set(
COALESCE(result, '{}'),
'{retry_count}',
($2)::jsonb
),
error = $3
WHERE id = $1
"#,
job_id,
serde_json::json!(retry_count + 1),
format!("Retry {} after error: {}", retry_count + 1, error)
)
.execute(&mut *tx)
.await?;
tracing::info!("Scheduled retry {} for job {}", retry_count + 1, job_id);
} else {
// Max retries exceeded, mark as permanently failed
sqlx::query!(
"UPDATE jobs SET status = 'failed', completed_at = NOW(), error = $2 WHERE id = $1",
job_id,
format!("Max retries exceeded. Last error: {}", error)
)
.execute(&mut *tx)
.await?;
tracing::error!("Job {} failed permanently after {} retries", job_id, retry_count);
}
tx.commit().await?;
Ok(())
}
}
Circuit Breaker Pattern
For handling cascading failures:
use std::sync::atomic::{AtomicU32, AtomicBool, Ordering};
pub struct CircuitBreaker {
failure_count: AtomicU32,
is_open: AtomicBool,
failure_threshold: u32,
reset_timeout: Duration,
last_failure: Arc<RwLock<Option<Instant>>>,
}
impl CircuitBreaker {
pub async fn call<F, T, E>(&self, operation: F) -> Result<T, CircuitBreakerError<E>>
where
F: Future<Output = Result<T, E>>,
{
if self.is_open() {
if self.should_attempt_reset().await {
self.half_open();
} else {
return Err(CircuitBreakerError::Open);
}
}
match operation.await {
Ok(result) => {
self.on_success();
Ok(result)
}
Err(error) => {
self.on_failure().await;
Err(CircuitBreakerError::Failure(error))
}
}
}
fn on_success(&self) {
self.failure_count.store(0, Ordering::SeqCst);
self.is_open.store(false, Ordering::SeqCst);
}
async fn on_failure(&self) {
let failures = self.failure_count.fetch_add(1, Ordering::SeqCst) + 1;
*self.last_failure.write().await = Some(Instant::now());
if failures >= self.failure_threshold {
self.is_open.store(true, Ordering::SeqCst);
tracing::warn!("Circuit breaker opened after {} failures", failures);
}
}
}
Part 4: Live Log Streaming
The Challenge
Real-time log streaming from distributed workers to clients requires:
- Low Latency: Logs should appear almost instantly
- Scalability: Support many concurrent streams
- Reliability: Handle network interruptions gracefully
- Ordering: Logs must arrive in the correct sequence
Server-Sent Events (SSE) Implementation
use axum::{
response::sse::{Event, KeepAlive, Sse},
extract::{Path, State},
response::Response,
};
use futures::Stream;
use tokio_stream::wrappers::BroadcastStream;
pub struct LogStreamer {
store: PersistentJobStore,
broadcasters: Arc<RwLock<HashMap<Uuid, broadcast::Sender<LogEntry>>>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LogEntry {
pub job_id: Uuid,
pub timestamp: DateTime<Utc>,
pub level: String,
pub message: String,
}
impl LogStreamer {
pub async fn stream_logs(
&self,
job_id: Uuid,
) -> impl Stream<Item = Result<Event, Infallible>> {
// Get existing logs first
let existing_logs = self.get_existing_logs(job_id).await.unwrap_or_default();
// Create or get broadcaster for this job
let receiver = {
let mut broadcasters = self.broadcasters.write().await;
let (tx, rx) = broadcasters
.entry(job_id)
.or_insert_with(|| broadcast::channel(1000).0)
.subscribe();
rx
};
// Combine existing logs with live stream
let existing_stream = stream::iter(existing_logs.into_iter().map(|log| {
Ok(Event::default()
.json_data(&log)
.unwrap()
.event("log"))
}));
let live_stream = BroadcastStream::new(receiver)
.filter_map(|result| async move {
match result {
Ok(log) => Some(Ok(Event::default()
.json_data(&log)
.unwrap()
.event("log"))),
Err(_) => None, // Handle lagged receivers
}
});
existing_stream.chain(live_stream)
}
pub async fn append_log(&self, job_id: Uuid, level: String, message: String) -> Result<(), JobError> {
let log_entry = LogEntry {
job_id,
timestamp: Utc::now(),
level: level.clone(),
message: message.clone(),
};
// Persist to database
sqlx::query!(
"INSERT INTO job_logs (job_id, level, message) VALUES ($1, $2, $3)",
job_id,
level,
message
)
.execute(&self.store.pool)
.await?;
// Broadcast to live listeners
if let Some(broadcaster) = self.broadcasters.read().await.get(&job_id) {
let _ = broadcaster.send(log_entry); // Ignore if no receivers
}
Ok(())
}
}
// Axum handler for SSE endpoint
pub async fn stream_job_logs(
Path(job_id): Path<Uuid>,
State(streamer): State<Arc<LogStreamer>>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let stream = streamer.stream_logs(job_id).await;
Sse::new(stream)
.keep_alive(KeepAlive::default())
}
WebSocket Alternative for Bidirectional Communication
use axum::extract::ws::{WebSocket, Message};
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
pub async fn websocket_log_handler(
ws: WebSocket,
job_id: Uuid,
streamer: Arc<LogStreamer>,
) {
let (mut sender, mut receiver) = ws.split();
// Spawn log streaming task
let streamer_clone = streamer.clone();
let sender_task = tokio::spawn(async move {
let mut log_stream = streamer_clone.stream_logs(job_id).await;
while let Some(log_result) = log_stream.next().await {
match log_result {
Ok(log_event) => {
let message = Message::Text(log_event.data);
if sender.send(message).await.is_err() {
break; // Client disconnected
}
}
Err(_) => break,
}
}
});
// Handle incoming messages (e.g., log level filtering)
let receiver_task = tokio::spawn(async move {
while let Some(msg) = receiver.next().await {
match msg {
Ok(Message::Text(text)) => {
// Handle client commands like changing log level
if let Ok(command) = serde_json::from_str::<LogCommand>(&text) {
// Process command...
}
}
Ok(Message::Close(_)) => break,
_ => {}
}
}
});
// Wait for either task to complete
tokio::select! {
_ = sender_task => {},
_ = receiver_task => {},
}
}
#[derive(Deserialize)]
enum LogCommand {
SetLevel(String),
Pause,
Resume,
}
Performance Optimizations
Log Batching for High-Throughput Jobs
pub struct BatchedLogStreamer {
batch_size: usize,
flush_interval: Duration,
pending_logs: Arc<Mutex<HashMap<Uuid, Vec<LogEntry>>>>,
}
impl BatchedLogStreamer {
pub async fn start_batch_flusher(&self) {
let mut interval = tokio::time::interval(self.flush_interval);
loop {
interval.tick().await;
self.flush_all_batches().await;
}
}
pub async fn append_log(&self, job_id: Uuid, level: String, message: String) {
let log_entry = LogEntry {
job_id,
timestamp: Utc::now(),
level,
message,
};
let mut pending = self.pending_logs.lock().await;
let job_logs = pending.entry(job_id).or_insert_with(Vec::new);
job_logs.push(log_entry);
if job_logs.len() >= self.batch_size {
let logs_to_flush = std::mem::take(job_logs);
drop(pending); // Release lock early
self.flush_batch(job_id, logs_to_flush).await;
}
}
async fn flush_batch(&self, job_id: Uuid, logs: Vec<LogEntry>) {
// Batch insert to database
let mut query_builder = sqlx::QueryBuilder::new(
"INSERT INTO job_logs (job_id, timestamp, level, message) "
);
query_builder.push_values(logs.iter(), |mut b, log| {
b.push_bind(log.job_id)
.push_bind(log.timestamp)
.push_bind(&log.level)
.push_bind(&log.message);
});
if let Err(e) = query_builder.build().execute(&self.pool).await {
tracing::error!("Failed to flush log batch: {}", e);
}
// Broadcast to listeners
if let Some(broadcaster) = self.broadcasters.read().await.get(&job_id) {
for log in logs {
let _ = broadcaster.send(log);
}
}
}
}
Part 5: Putting It All Together
The Complete Job Executor
pub struct MiniWindmill {
store: PersistentJobStore,
log_streamer: Arc<LogStreamer>,
zombie_detector: ZombieJobDetector,
error_recovery: ErrorRecoveryManager,
worker_pool: WorkerPool,
}
impl MiniWindmill {
pub async fn new(database_url: &str) -> Result<Self, Box<dyn std::error::Error>> {
let store = PersistentJobStore::new(database_url).await?;
let log_streamer = Arc::new(LogStreamer::new(store.clone()));
let zombie_detector = ZombieJobDetector::new(store.clone());
let error_recovery = ErrorRecoveryManager::new(store.clone());
let worker_pool = WorkerPool::new(4, store.clone(), log_streamer.clone());
Ok(Self {
store,
log_streamer,
zombie_detector,
error_recovery,
worker_pool,
})
}
pub async fn start(&self) -> Result<(), Box<dyn std::error::Error>> {
// Start background services
let zombie_detector = self.zombie_detector.clone();
tokio::spawn(async move {
zombie_detector.start_heartbeat_monitoring().await;
});
// Start worker pool
self.worker_pool.start().await?;
// Start web server for APIs and SSE
self.start_web_server().await?;
Ok(())
}
async fn start_web_server(&self) -> Result<(), Box<dyn std::error::Error>> {
let app = Router::new()
.route("/jobs", post(create_job))
.route("/jobs/:id", get(get_job))
.route("/jobs/:id/logs", get(stream_job_logs))
.route("/jobs/:id/ws", get(websocket_handler))
.with_state(AppState {
store: self.store.clone(),
log_streamer: self.log_streamer.clone(),
});
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
}
// API handlers
async fn create_job(
State(state): State<AppState>,
Json(payload): Json<CreateJobRequest>,
) -> Result<Json<CreateJobResponse>, AppError> {
let job_id = state.store.create_job(&payload.script_content).await?;
Ok(Json(CreateJobResponse { job_id }))
}
#[derive(Serialize)]
struct CreateJobResponse {
job_id: Uuid,
}
#[derive(Deserialize)]
struct CreateJobRequest {
script_content: String,
}
Testing the Complete System
#[tokio::test]
async fn test_complete_job_execution() {
// Start mini windmill
let windmill = MiniWindmill::new("postgresql://localhost/test").await.unwrap();
// Create a job
let job_id = windmill.store.create_job("print('Hello, World!')").await.unwrap();
// Set up log streaming
let mut log_stream = windmill.log_streamer.stream_logs(job_id).await;
// Job should be picked up by worker and executed
tokio::time::sleep(Duration::from_secs(1)).await;
// Check job status
let job = windmill.store.get_job(job_id).await.unwrap();
assert_eq!(job.status, JobStatus::Completed);
// Verify logs were streamed
let log_count = log_stream.take(10).count().await;
assert!(log_count > 0);
}
#[tokio::test]
async fn test_zombie_job_recovery() {
let windmill = MiniWindmill::new("postgresql://localhost/test").await.unwrap();
// Create a job and manually mark it as running (simulate crashed worker)
let job_id = windmill.store.create_job("sleep(60)").await.unwrap();
windmill.store.transition_to_running(job_id).await.unwrap();
// Simulate time passing beyond zombie threshold
// (In real test, we'd mock the time or adjust thresholds)
// Trigger zombie detection
windmill.zombie_detector.detect_and_recover_zombies().await.unwrap();
// Job should be back in queue
let job = windmill.store.get_job(job_id).await.unwrap();
assert_eq!(job.status, JobStatus::Queued);
}
Key Engineering Insights
1. State Management is Critical
The finite state machine approach provides:
- Predictability: Valid transitions are defined upfront
- Debuggability: Current state is always clear
- Consistency: Prevents impossible states
2. Database Design for Performance
- Indexes: Critical for job queue performance
- SKIP LOCKED: Enables efficient distributed polling
- Transactions: Ensure state consistency
- JSONB: Flexible storage for job metadata
3. Observability from Day One
- Event Streams: Every state change emits events
- Structured Logging: Machine-readable log format
- Metrics: Performance monitoring built-in
- Real-time Streaming: Immediate feedback
4. Failure is Normal
- Zombie Detection: Workers will crash
- Retry Logic: Transient failures are common
- Circuit Breakers: Prevent cascading failures
- Dead Letter Queues: Some jobs will never succeed
5. Performance Through Design
- Async Everything: Non-blocking operations
- Connection Pooling: Database efficiency
- Batch Operations: Reduce I/O overhead
- Smart Polling: Avoid busy waiting
Next Steps: Scaling Beyond Mini-Windmill
Horizontal Scaling Challenges
- Distributed Locking: Use PostgreSQL advisory locks or Redis
- Leader Election: Coordinate zombie detection across instances
- Sharding: Partition jobs across multiple databases
- Load Balancing: Distribute HTTP and WebSocket connections
Advanced Features
- Priority Queues: Weighted job scheduling
- Resource Constraints: CPU/Memory limits per job
- Dependency Management: Job chains and DAGs
- Multi-tenancy: Workspace isolation
- Audit Logging: Compliance and debugging
Production Considerations
- Monitoring: Prometheus metrics and Grafana dashboards
- Security: Authentication, authorization, and sandboxing
- Backup: Database and log retention policies
- Deployment: Container orchestration and blue-green deploys
Conclusion
We've built a simplified but functional job execution system that demonstrates the core principles behind Windmill's architecture. The key takeaways:
- Start Simple: Begin with a clear state machine
- Add Persistence: Database design drives performance
- Handle Failures: Zombie detection and recovery are essential
- Enable Observability: Real-time logs and metrics are crucial
- Design for Scale: Async, batched, and distributed from the start
This foundation provides the building blocks for sophisticated workflow orchestration systems. The patterns we've explored - state machines, event sourcing, circuit breakers, and real-time streaming - are fundamental to building reliable distributed systems.
Remember: Complexity is the enemy of reliability. Start with the simplest solution that works, then evolve based on real-world requirements.
Happy building! 🚀