Ditching Celery: Building an Ultra-Lightweight Background Task Queue Using Python’s Native Asyncio and SQLite WAL Mode

"Ditching Celery: Building an Ultra-Lightweight Background Task Queue Using Python’s Native Asyncio and SQLite WAL Mode"


Build a lightweight background task queue in Python using asyncio and SQLite WAL mode. Zero external dependencies, excellent performance. 


Celery is powerful, but it brings heavy dependencies like Redis or RabbitMQ, extra processes, and significant operational complexity. For many applications, you can build a fast, reliable background task queue using only Python’s native asyncio and SQLite in WAL mode.

This solution is extremely lightweight, requires zero external services, and delivers excellent performance for most use cases.

I replaced Celery in a medium-scale web service with this approach. The system now runs with dramatically lower resource usage and simpler deployment while maintaining reliable task execution.






The Production-Ready Implementation

Here is the complete, robust task queue:


import asyncio
import sqlite3
import json
import time
import uuid
from typing import Callable, Dict, Any, Optional

class LiteQueue:
    def __init__(self, db_path: str = "tasks.db", max_retries: int = 3):
        self.db_path = db_path
        self.max_retries = max_retries
        self.conn = sqlite3.connect(db_path, check_same_thread=False)
        self.conn.row_factory = sqlite3.Row
        self._init_db()
        self.running = False

    def _init_db(self):
        with self.conn:
            self.conn.execute("PRAGMA journal_mode=WAL;")
            self.conn.execute("PRAGMA synchronous=NORMAL;")
            self.conn.execute("PRAGMA cache_size=-20000;")
            self.conn.execute("""
                CREATE TABLE IF NOT EXISTS tasks (
                    id TEXT PRIMARY KEY,
                    task_name TEXT NOT NULL,
                    payload TEXT NOT NULL,
                    status TEXT DEFAULT 'pending',
                    created_at REAL DEFAULT (unixepoch()),
                    scheduled_at REAL DEFAULT (unixepoch()),
                    attempts INTEGER DEFAULT 0
                )
            """)

    async def enqueue(self, task_name: str, payload: Dict[str, Any], delay_seconds: int = 0) -> str:
        task_id = str(uuid.uuid4())
        scheduled_at = time.time() + delay_seconds
        payload_str = json.dumps(payload)
        
        def _insert():
            with self.conn:
                self.conn.execute(
                    "INSERT INTO tasks (id, task_name, payload, scheduled_at) VALUES (?, ?, ?, ?)",
                    (task_id, task_name, payload_str, scheduled_at)
                )
            return task_id
            
        return await asyncio.to_thread(_insert)

    async def worker(self, task_handlers: Dict[str, Callable], poll_interval: float = 0.2):
        self.running = True
        print("🚀 LiteQueue production-ready worker started...")
        
        while self.running:
            try:
                task = await asyncio.to_thread(self._claim_next_task)
                
                if not task:
                    await asyncio.sleep(poll_interval)
                    continue
                    
                task_id, task_name, payload_str, attempts = task
                payload = json.loads(payload_str)
                handler = task_handlers.get(task_name)
                
                if not handler:
                    print(f"❌ No handler registered for task: {task_name}")
                    await asyncio.to_thread(self._update_status, task_id, 'failed')
                    continue
                
                try:
                    await handler(payload)
                    await asyncio.to_thread(self._update_status, task_id, 'completed')
                except Exception as exc:
                    print(f"⚠️ Task {task_id} execution failed: {exc}")
                    await asyncio.to_thread(self._handle_failure, task_id, attempts)
                    
            except Exception as e:
                print(f"🚨 Worker pipeline anomaly: {e}")
                await asyncio.sleep(1)

    def _claim_next_task(self) -> Optional[tuple]:
        """Atomically claim the next task to prevent race conditions."""
        with self.conn:
            cursor = self.conn.execute("""
                SELECT id, task_name, payload, attempts 
                FROM tasks 
                WHERE status = 'pending' AND scheduled_at <= ? 
                ORDER BY created_at ASC LIMIT 1
            """, (time.time(),))
            row = cursor.fetchone()
            
            if not row:
                return None
            
            task_id, task_name, payload_str, attempts = row[0], row[1], row[2], row[3]
            
            # Claim-First: Lock the task before yielding control
            self.conn.execute(
                "UPDATE tasks SET status = 'processing' WHERE id = ? AND status = 'pending'", 
                (task_id,)
            )
            return (task_id, task_name, payload_str, attempts)

    def _update_status(self, task_id: str, status: str):
        with self.conn:
            self.conn.execute("UPDATE tasks SET status = ? WHERE id = ?", (status, task_id))

    def _handle_failure(self, task_id: str, current_attempts: int):
        next_attempts = current_attempts + 1
        with self.conn:
            if next_attempts >= self.max_retries:
                self.conn.execute("UPDATE tasks SET status = 'failed', attempts = ? WHERE id = ?", (next_attempts, task_id))
            else:
                # Exponential Backoff: Δt = 2^attempts × 10 seconds
                backoff_delay = (2 ** current_attempts) * 10
                new_scheduled_at = time.time() + backoff_delay
                self.conn.execute("""
                    UPDATE tasks 
                    SET status = 'pending', attempts = ?, scheduled_at = ? 
                    WHERE id = ?
                """, (next_attempts, new_scheduled_at, task_id))

    def close(self):
        self.running = False
        self.conn.close()



Intelligent Error Resilience with Exponential Backoff

Hard-failing a network-dependent background job on the first exception is poor design. The production version tracks execution attempts and reschedules failures dynamically using an exponential backoff equation:

Δt=2attempts×10seconds

This gives the external target service breathing room to recover from transient issues without flooding your server logs or dropping transactions entirely.



The Limits of In-Process Queues

Shifting away from Celery is a massive win for stack minimization, but keep these architectural boundaries in mind:

  • Horizontal Scaling Hurdles: SQLite is a local file-system database. If your web app grows and you deploy it behind a load balancer across multiple physical servers, each node will only see its own local tasks.db instance.
  • Hard Crashes / SIGKILL Vulnerability: If a task state is set to processing and your server encounters a sudden Out-of-Memory event or power loss, that specific task remains stuck in a perpetual processing limbo. You must implement a tiny startup routine to sweep and recover stalled tasks.
For more information on SQLite WAL mode, see the official SQLite WAL documentation.


Final Thoughts

By shifting security left isn’t a management philosophy — it’s an operational implementation. By forcing your local pre-commit hook to execute zero-disk streaming diff validation against Shannon’s entropy bounds, you create an un-bypassable cryptographic gate. Secrets are intercepted at the memory buffer stage, long before they can ever leak into the history logs of a remote repository.

Post a Comment

Previous Post Next Post