Skip to content

Custom Workers

Workers are the backbone of Mailyte. Each worker handles a specific background job — tracking, webhooks, analytics, etc. This guide shows you how to build a new one.

Worker Architecture

Every worker is a standalone Python service running in its own Docker container. Workers share:

  • A database connection (MySQL)
  • A cache connection (Redis)
  • A health check endpoint (/health)
  • A metrics endpoint (/metrics)
  • The shared/ Python package for common utilities

File Structure

Create a new directory under worker/:

Text Only
worker/
  my_new_worker/
    __init__.py
    main.py              # Entry point
    service.py           # Core business logic
    routes.py            # HTTP routes (health, metrics, any APIs)
    config.py            # Configuration from env vars
    models.py            # Data models
    Dockerfile           # Container build
    requirements.txt     # Python dependencies

Step 1: Configuration

Python
# worker/my_new_worker/config.py
import os


class Config:
    # Database
    DB_HOST = os.environ.get("DB_HOST", "mysql")
    DB_PORT = int(os.environ.get("DB_PORT", 3306))
    DB_NAME = os.environ.get("DB_NAME", "mailserver")
    DB_USER = os.environ.get("DB_USER", "mailuser")
    DB_PASSWORD = os.environ.get("DB_PASSWORD", "")

    # Redis
    REDIS_HOST = os.environ.get("REDIS_HOST", "redis")
    REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379))

    # Worker-specific
    SERVICE_PORT = int(os.environ.get("PORT", 8090))
    POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 60))
    WORKER_THREADS = int(os.environ.get("WORKER_THREADS", 2))

Step 2: Core Service Logic

Python
# worker/my_new_worker/service.py
import logging
import threading
import time

logger = logging.getLogger(__name__)


class MyNewWorkerService:
    def __init__(self, config, db, redis_client):
        self.config = config
        self.db = db
        self.redis = redis_client
        self._running = False
        self._thread = None

    def start(self):
        """Start the worker loop in a background thread."""
        self._running = True
        self._thread = threading.Thread(target=self._run_loop, daemon=True)
        self._thread.start()
        logger.info("MyNewWorker started (poll interval: %ds)", self.config.POLL_INTERVAL)

    def stop(self):
        """Stop the worker loop."""
        self._running = False
        if self._thread:
            self._thread.join(timeout=10)
        logger.info("MyNewWorker stopped")

    def _run_loop(self):
        """Main worker loop."""
        while self._running:
            try:
                self.process()
            except Exception as e:
                logger.error("Worker loop error: %s", e)
            time.sleep(self.config.POLL_INTERVAL)

    def process(self):
        """Override this with your actual work."""
        logger.debug("Processing...")
        # Your logic here:
        # - Query the database for items to process
        # - Do the work
        # - Update the database with results
        # - Emit metrics

    def health_check(self) -> dict:
        """Return worker health status."""
        return {
            "status": "healthy" if self._running else "unhealthy",
            "service": "my_new_worker",
            "thread_alive": self._thread.is_alive() if self._thread else False,
        }

Step 3: HTTP Routes

Python
# worker/my_new_worker/routes.py
from fastapi import FastAPI
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
from starlette.responses import Response


def create_app(service) -> FastAPI:
    app = FastAPI(title="My New Worker")

    @app.get("/health")
    async def health():
        return service.health_check()

    @app.get("/metrics")
    async def metrics():
        return Response(
            content=generate_latest(),
            media_type=CONTENT_TYPE_LATEST,
        )

    return app

Step 4: Entry Point

Python
# worker/my_new_worker/main.py
import logging
import uvicorn

from config import Config
from service import MyNewWorkerService
from routes import create_app

# Set up logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)


def main():
    config = Config()

    # Initialize connections
    from shared.database import get_connection
    from shared.redis_client import get_redis

    db = get_connection(config)
    redis_client = get_redis(config)

    # Create and start the worker
    service = MyNewWorkerService(config, db, redis_client)
    service.start()

    # Create the HTTP app (health + metrics)
    app = create_app(service)

    # Run the HTTP server
    logger.info("Starting HTTP server on port %d", config.SERVICE_PORT)
    uvicorn.run(app, host="0.0.0.0", port=config.SERVICE_PORT)


if __name__ == "__main__":
    main()

Step 5: Dockerfile

Docker
# worker/my_new_worker/Dockerfile
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .
COPY ../../shared /app/shared

EXPOSE 8090

CMD ["python", "main.py"]

Step 6: Requirements

Text Only
# worker/my_new_worker/requirements.txt
fastapi==0.109.0
uvicorn==0.27.0
mysql-connector-python==8.3.0
redis==5.0.1
prometheus-client==0.19.0

Step 7: Docker Compose Registration

Add the worker to docker-compose.yml:

YAML
my_new_worker:
  build: ./worker/my_new_worker
  container_name: my_new_worker
  ports:
    - "8090:8090"
  environment:
    - PORT=8090
    - DB_HOST=mysql
    - DB_PORT=3306
    - DB_NAME=${DB_NAME:-mailserver}
    - DB_USER=${DB_USER:-mailuser}
    - DB_PASSWORD=${DB_PASSWORD:-mailpassword}
    - REDIS_HOST=redis
    - REDIS_PORT=6379
    - POLL_INTERVAL=60
  volumes:
    - ./worker/my_new_worker:/app
    - ./shared:/app/shared
    - ./logs/worker/my_new_worker:/app/logs
  depends_on:
    mysql:
      condition: service_healthy
    redis:
      condition: service_healthy
  networks:
    - mailserver_network
  restart: unless-stopped
  healthcheck:
    test: ["CMD-SHELL", "python3 -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8090/health\")'"]
    interval: 30s
    timeout: 10s
    retries: 3
    start_period: 15s

Step 8: Add Prometheus Metrics

Python
# In service.py
from prometheus_client import Counter, Histogram, Gauge

# Define metrics
ITEMS_PROCESSED = Counter(
    "mailyte_my_worker_items_processed_total",
    "Total items processed",
    ["status"],
)
PROCESSING_DURATION = Histogram(
    "mailyte_my_worker_processing_seconds",
    "Time to process each item",
)
QUEUE_SIZE = Gauge(
    "mailyte_my_worker_queue_size",
    "Number of items waiting to be processed",
)


class MyNewWorkerService:
    def process(self):
        # Update queue size
        pending = self.db.execute("SELECT COUNT(*) FROM my_table WHERE status = 'pending'")
        QUEUE_SIZE.set(pending)

        # Process items
        with PROCESSING_DURATION.time():
            result = self.do_work()

        if result.success:
            ITEMS_PROCESSED.labels(status="success").inc()
        else:
            ITEMS_PROCESSED.labels(status="failure").inc()

Add the scrape target to Prometheus config:

YAML
# monitoring/prometheus/prometheus.yml
- job_name: "mailyte-my-new-worker"
  static_configs:
    - targets: ["my_new_worker:8090"]
      labels:
        service: "my-new-worker"
        component: "worker"

Checklist

  • Worker has a clear, single responsibility
  • Config reads from environment variables
  • Health check endpoint at /health
  • Prometheus metrics at /metrics
  • Dockerfile and requirements.txt
  • Registered in docker-compose.yml
  • Proper error handling with logging
  • Graceful shutdown support
  • Added to Prometheus scrape config
  • Tests written