Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Python / uv
__pycache__/
*.py[cod]
*$py.class
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.ty_cache/
*.egg-info/
dist/
build/

# Node / Vite
node_modules/
host/client_app/dist/
packages/*/dist/
.npm/

# Editors / OS
.vscode/
.idea/
.DS_Store

# Secrets / local data
.env
*.db
*.sqlite
.memray/

# Tests / docs not needed at runtime
tests/e2e/
docs/
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,13 @@ SM_USERS_COOKIE_SECURE=false
# Multi-tenancy (default off).
# SM_MULTI_TENANT=false
# SM_TENANT_HEADER=

# BackgroundTasks module — Celery + Redis task queue.
# In docker-compose these point at the `redis` service host (redis://redis:6379/*).
SM_BG_TASKS_BROKER_URL=redis://localhost:6379/0
SM_BG_TASKS_RESULT_BACKEND=redis://localhost:6379/1
# How long a task can stay in `running` without a heartbeat before it's
# flipped to `stuck` by the beat sweep.
SM_BG_TASKS_STUCK_AFTER_SECONDS=300
# Purge terminal rows older than this (days) — keeps history bounded.
SM_BG_TASKS_RETENTION_DAYS=14
15 changes: 13 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-memray lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-memray lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size worker beat worker-docker

# Install
install:
Expand Down Expand Up @@ -143,7 +143,18 @@ kill:

# Docker
docker-up:
docker compose up -d
docker compose up -d postgres redis

docker-down:
docker compose down

# Celery — local dev (fast reload, no container rebuild)
worker: ## Run a Celery worker locally against $(SM_BG_TASKS_BROKER_URL)
uv run celery -A scripts.run_worker:celery worker -l info

beat: ## Run the Celery beat scheduler locally
uv run celery -A scripts.run_worker:celery beat -l info

# Celery — containerized (matches prod image)
worker-docker: ## Build + run the worker + beat services in docker
docker compose up --build worker beat
67 changes: 67 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,73 @@ services:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sm -d simple_module"]
interval: 5s
timeout: 5s
retries: 10

redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10

worker:
build:
context: .
dockerfile: docker/worker.Dockerfile
env_file: .env
environment:
SM_BG_TASKS_BROKER_URL: redis://redis:6379/0
SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1
SM_DATABASE_URL: ${SM_DATABASE_URL:-postgresql+asyncpg://sm:sm@postgres:5432/simple_module}
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
command:
- "uv"
- "run"
- "celery"
- "-A"
- "scripts.run_worker:celery"
- "worker"
- "-l"
- "info"
- "--concurrency=4"

beat:
build:
context: .
dockerfile: docker/worker.Dockerfile
env_file: .env
environment:
SM_BG_TASKS_BROKER_URL: redis://redis:6379/0
SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1
SM_DATABASE_URL: ${SM_DATABASE_URL:-postgresql+asyncpg://sm:sm@postgres:5432/simple_module}
depends_on:
redis:
condition: service_healthy
worker:
condition: service_started
command:
- "uv"
- "run"
- "celery"
- "-A"
- "scripts.run_worker:celery"
- "beat"
- "-l"
- "info"

volumes:
pgdata:
redisdata:
51 changes: 51 additions & 0 deletions docker/worker.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Celery worker image for the BackgroundTasks module.
#
# Serves both the ``worker`` and ``beat`` services in docker-compose — they
# differ only by command. A separate image (rather than reusing a web one)
# keeps the runtime surface narrow: no Vite, no static assets, no hot-reload
# tooling. Everything needed is ``uv sync`` + the project source.

FROM python:3.12-slim AS base

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
UV_SYSTEM_PYTHON=1

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
ca-certificates \
build-essential \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir uv

WORKDIR /app

# Dependency-only layer so code changes don't bust the wheel cache.
COPY pyproject.toml uv.lock ./
COPY framework/ framework/
COPY modules/ modules/
COPY host/ host/
COPY scripts/ scripts/

# Install the full workspace (all framework + module packages) without dev
# extras. Production workers never need pytest / ruff / ty.
RUN uv sync --all-packages --frozen --no-dev

# Drop privileges. uv writes to /app/.venv so it needs write access until
# sync completes; chown after install to keep the build step rootful.
RUN useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin worker \
&& chown -R worker:worker /app
USER worker

# Container-native healthcheck: ask the worker to ping itself via Celery's
# control protocol. Fails fast if the worker has crashed but the PID lingers.
ENV CELERY_APP=scripts.run_worker:celery
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD uv run celery -A $CELERY_APP inspect ping -d celery@$HOSTNAME || exit 1

# Default command is the worker; docker-compose overrides to `beat` for the
# scheduler service.
CMD ["uv", "run", "celery", "-A", "scripts.run_worker:celery", "worker", "-l", "info"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""add background_tasks_task_execution

Revision ID: 6df7645cc4d2
Revises: b7e1af4c9d02
Create Date: 2026-04-19 07:44:06.824688
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "6df7645cc4d2"
down_revision: str | None = "b7e1af4c9d02"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"background_tasks_task_execution",
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.String(length=255), nullable=True),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("celery_task_id", sa.String(length=64), nullable=True),
sa.Column("task_name", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("queue", sa.String(length=64), nullable=False),
sa.Column("args", sa.JSON(), nullable=True),
sa.Column("kwargs", sa.JSON(), nullable=True),
sa.Column("result", sa.JSON(), nullable=True),
sa.Column("traceback", sa.String(), nullable=True),
sa.Column("exception_type", sa.String(length=255), nullable=True),
sa.Column("worker", sa.String(length=255), nullable=True),
sa.Column("retries", sa.Integer(), nullable=False),
sa.Column("retried_from_id", sa.Uuid(), nullable=True),
sa.Column("queued_at", sa.DateTime(), nullable=True),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("finished_at", sa.DateTime(), nullable=True),
sa.Column("heartbeat_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["retried_from_id"],
["background_tasks_task_execution.id"],
name=op.f(
"fk_background_tasks_task_execution_retried_from_id_background_tasks_task_execution"
),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_background_tasks_task_execution")),
)
op.create_index(
op.f("ix_background_tasks_task_execution_celery_task_id"),
"background_tasks_task_execution",
["celery_task_id"],
unique=False,
)
op.create_index(
op.f("ix_background_tasks_task_execution_retried_from_id"),
"background_tasks_task_execution",
["retried_from_id"],
unique=False,
)
op.create_index(
op.f("ix_background_tasks_task_execution_status"),
"background_tasks_task_execution",
["status"],
unique=False,
)
op.create_index(
"ix_background_tasks_task_execution_status_queued",
"background_tasks_task_execution",
["status", "queued_at"],
unique=False,
)
op.create_index(
op.f("ix_background_tasks_task_execution_task_name"),
"background_tasks_task_execution",
["task_name"],
unique=False,
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(
op.f("ix_background_tasks_task_execution_task_name"),
table_name="background_tasks_task_execution",
)
op.drop_index(
"ix_background_tasks_task_execution_status_queued",
table_name="background_tasks_task_execution",
)
op.drop_index(
op.f("ix_background_tasks_task_execution_status"),
table_name="background_tasks_task_execution",
)
op.drop_index(
op.f("ix_background_tasks_task_execution_retried_from_id"),
table_name="background_tasks_task_execution",
)
op.drop_index(
op.f("ix_background_tasks_task_execution_celery_task_id"),
table_name="background_tasks_task_execution",
)
op.drop_table("background_tasks_task_execution")
# ### end Alembic commands ###
2 changes: 2 additions & 0 deletions host/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dependencies = [
"auth",
"dashboard",
"products",
"background-tasks",
"file-storage",
"python-multipart>=0.0.6",
]
Expand All @@ -17,4 +18,5 @@ simple-module-hosting = { workspace = true }
auth = { workspace = true }
products = { workspace = true }
dashboard = { workspace = true }
background-tasks = { workspace = true }
file-storage = { workspace = true }
1 change: 1 addition & 0 deletions modules/background_tasks/background_tasks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""BackgroundTasks module — Celery + Redis task queue with admin UI."""
Loading
Loading