Skip to content

Commit 31d1305

Browse files
authored
🥦 [GH-83] Split up celery beat and worker options (#85)
* chore: split celery beat/worker options [#83] * chore: bump packages and include redis extra [#83] * docs: update documentation [#83] * chore: drop fastapi-cache2 [#83] * chore: lint [#83] * chore: more lint [#83] * chore: more more lint [#83] * meh
1 parent 72c2f87 commit 31d1305

11 files changed

Lines changed: 146 additions & 25 deletions

File tree

.github/workflows/test.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,10 @@ jobs:
4848
args: ""
4949
- name: Deployments[render], Example Api, py3.11, & celery[yes]
5050
args: "deployments='render' py_version=3.11 use_celery='yes'"
51-
- name: Deployments[render], Example Api, py3.9, celery[yes], & sentry[yes]
52-
args: "deployments='render' py_version=3.9 use_celery='yes' use_sentry='yes'"
51+
- name: Deployments[digitalocean], Example Api, py3.9, celery[yes], & sentry[yes]
52+
args: "deployments='digitalocean' py_version=3.9 use_celery='yes' use_sentry='yes'"
53+
- name: Deployments[render], Example Api, py3.10, celery[yes], beat[yes], & sentry[no]
54+
args: "deployments='digitalocean' py_version=3.9 use_celery='yes' periodic_tasks='yes' use_sentry='no'"
5355
name: "Docker ${{ matrix.script.name }}"
5456
runs-on: ubuntu-latest
5557
env:

cookiecutter.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,17 @@
1010
"db_container_name": "db",
1111
"backend_container_name": "backend",
1212
"use_celery": ["no", "yes"],
13+
"periodic_tasks": ["no", "yes"],
1314
"use_sentry": ["no", "yes"],
1415
"github_username": "change.me",
1516

16-
"deployments": ["none", "render", "digitalocean"]
17+
"deployments": ["none", "render", "digitalocean"],
18+
19+
"__prompts__": {
20+
"use_celery": "Do you want to enable Celery for background tasks?",
21+
"periodic_tasks": "Do you want to enable Celery Beat for periodic tasks (requires Celery)?",
22+
"use_sentry": "Do you want to enable Sentry for error tracking?",
23+
"github_username": "What is your GitHub username?",
24+
"deployments": "Do you want to enable deployment to a platform-as-a-service (PaaS) provider?"
25+
}
1726
}
Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,65 @@
11
Asynchronous Tasks with Celery
22
==============================
33

4-
The project uses Celery for asynchronous tasks, with Celery beat for task scheduling.
4+
The project uses Celery for asynchronous tasks, with Celery beat for task scheduling, and Redis is used as the broker by default.
55

6-
Redis is used as the broker by default.
6+
.. note::
77

8-
You can read more here on how to get started with `Celery <https://docs.celeryq.dev/en/stable/getting-started/first-steps-with-celery.html>`_
8+
You can read more here on how to get started with `Celery <https://docs.celeryq.dev/en/stable/getting-started/first-steps-with-celery.html>`_
9+
10+
FastAPI comes with built-in `BackgroundTasks <https://fastapi.tiangolo.com/tutorial/background-tasks/>`_, however, they run in a single
11+
process. This won't work well for more complex tasks, especially at scale. The Celery worker and beat service can scale independently of
12+
the FastAPI application, which is how typical async applications are deployed.
13+
14+
Configuring Celery Workers
15+
--------------------------
16+
17+
Celery workers can be configured to run on a specific number of workers. This is useful for scaling the application to handle more requests.
18+
19+
.. code-block:: python
20+
21+
...
22+
23+
@router.get("/ping", tags=["health"])
24+
def pong() -> Response:
25+
hello_world_task.delay()
26+
# or a task with arguments
27+
my_task.delay(id=1)
28+
return JSONResponse({"ping": "pong!"})
29+
30+
You can read more about the different application settings `here <https://docs.celeryq.dev/en/v5.2.7/userguide/application.html#application>`_.
31+
32+
33+
Configuring Celery Beat
34+
-----------------------
35+
36+
Celery beat is used to schedule periodic tasks. It can be configured to run on a schedule, or to run on a fixed time interval.
37+
38+
.. code-block:: python
39+
40+
# worker.py
41+
42+
from celery.schedules import crontab
43+
44+
...
45+
app.conf.beat_schedule = {
46+
# Executes every Monday morning at 7:30 a.m.
47+
'add-every-monday-morning': {
48+
'task': 'src.core.tasks.add',
49+
'schedule': crontab(hour=7, minute=30, day_of_week=1),
50+
'args': (16, 16),
51+
},
52+
# Executes every 3rd minute
53+
'hello-every-third-minute': {
54+
'task': 'src.core.tasks.hello_world_task',
55+
'schedule': crontab(minute="*/3"),
56+
},
57+
# Executes at sunset in Melbourne
58+
'add-at-melbourne-sunset': {
59+
'task': 'src.core.tasks.melbourne_add',
60+
'schedule': solar('sunset', -37.81753, 144.96715),
61+
'args': (16, 16),
62+
},
63+
}
64+
65+
You can read more about the different schedule `types <https://docs.celeryq.dev/en/v5.2.7/userguide/periodic-tasks.html#crontab-schedules>`_.

hooks/pre_gen_project.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,13 @@
1515

1616
# Exit to cancel project
1717
sys.exit(1)
18+
19+
20+
if (
21+
"{{ cookiecutter.use_celery}}" == "no"
22+
and "{{ cookiecutter.periodic_tasks}}" == "yes"
23+
):
24+
print(
25+
"ERROR: Celery is not enabled, but periodic tasks are requested. Please enable Celery."
26+
)
27+
sys.exit(1)

tests/test_bake_project.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import pytest
77
from binaryornot.check import is_binary
8+
from cookiecutter.exceptions import FailedHookException
89

910
try:
1011
import sh
@@ -21,14 +22,18 @@
2122
{"deployments": "none"},
2223
{"deployments": "render"},
2324
{"deployments": "digitalocean"},
24-
{"use_celery": "no"},
25-
{"use_celery": "yes"},
25+
{"use_celery": "no", "periodic_tasks": "no"},
26+
{"use_celery": "yes", "periodic_tasks": "no"},
27+
{"use_celery": "yes", "periodic_tasks": "yes"},
2628
{"py_version": "3.9"},
2729
{"py_version": "3.10"},
2830
{"py_version": "3.11"},
2931
{"use_sentry": "no"},
3032
{"use_sentry": "yes"},
3133
]
34+
UNSUPPORTED_COMBINATIONS = [
35+
{"use_celery": "no", "periodic_tasks": "yes"},
36+
]
3237
PATTERN = r"{{(\s?cookiecutter)[.](.*?)}}"
3338
RE_OBJ = re.compile(PATTERN)
3439

@@ -88,6 +93,27 @@ def test_project_generation(cookies, context, context_override) -> None:
8893
check_paths(paths)
8994

9095

96+
@pytest.mark.parametrize("slug", ["project slug", "Project_Slug"])
97+
def test_invalid_slug(cookies, context, slug):
98+
"""Invalid slug should fail pre-generation hook."""
99+
context.update({"project_slug": slug})
100+
101+
result = cookies.bake(extra_context=context)
102+
103+
assert result.exit_code != 0
104+
assert isinstance(result.exception, FailedHookException)
105+
106+
107+
@pytest.mark.parametrize("invalid_context", UNSUPPORTED_COMBINATIONS)
108+
def test_error_if_incompatible(cookies, context, invalid_context):
109+
"""It should not generate project an incompatible combination is selected."""
110+
context.update(invalid_context)
111+
result = cookies.bake(extra_context=context)
112+
113+
assert result.exit_code != 0
114+
assert isinstance(result.exception, FailedHookException)
115+
116+
91117
@pytest.mark.parametrize("context_override", SUPPORTED_COMBINATIONS, ids=_fixture_id)
92118
def test_pre_commit_hooks(cookies, context_override) -> None:
93119
"""Generated project pre-commit hooks run successfully."""

{{ cookiecutter.project_slug }}/docker-compose.yml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,7 @@ services:
4848
{%- if cookiecutter.use_celery == "yes" %}
4949
redis:
5050
restart: always
51-
image: redis:latest
52-
ports:
53-
- "6479:6379"
51+
image: redis:7-alpine
5452
volumes:
5553
- redis-data:/data
5654

@@ -63,7 +61,8 @@ services:
6361
depends_on:
6462
- {{ cookiecutter.backend_container_name }}
6563
- redis
66-
64+
{%- endif %}
65+
{%- if cookiecutter.periodic_tasks == "yes" %}
6766
beat:
6867
restart: always
6968
build: ./{{ cookiecutter.backend_container_name }}

{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/pyproject.toml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ dependencies = [
1414
"fastapi==0.116.1",
1515
"pydantic-settings==2.10.1",
1616
"sqlmodel==0.0.24",
17-
{%- if cookiecutter.use_celery == "yes" %}
18-
"redis==4.6.0",
19-
"fastapi-cache2[redis]==0.2.2",
20-
"celery[beat]==5.2.7",{%- endif %}
17+
{%- if cookiecutter.use_celery == "yes" and cookiecutter.periodic_tasks == "yes" %}
18+
"redis==6.2.0",
19+
"celery[beat,redis]==5.2.7",
20+
{%- elif cookiecutter.use_celery == "yes" %}
21+
"redis==6.2.0",
22+
"celery[redis]==5.2.7",{%- endif %}
2123
"uvicorn[standard]==0.35.0",
2224
"PyYAML==6.0.1",
2325
"httpx==0.28.1",

{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/api/deps.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import cast
2+
13
from redis import Redis
24

35
from src.core.config import settings
@@ -14,4 +16,4 @@ def get_redis_client() -> Redis:
1416
encoding="utf8",
1517
decode_responses=True,
1618
)
17-
return redis
19+
return cast(Redis, redis)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from src.worker import celery_app
2+
3+
4+
@celery_app.task # type: ignore
5+
def hello_world_task() -> None:
6+
print("Hello World")

{{ cookiecutter.project_slug }}/{{ cookiecutter.backend_container_name }}/src/main.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,8 @@
33
import sentry_sdk{% endif %}
44
from fastapi import FastAPI
55
from fastapi.middleware.cors import CORSMiddleware
6-
{%- if cookiecutter.use_celery == "yes" %}
7-
from fastapi_cache import FastAPICache
8-
from fastapi_cache.backends.redis import RedisBackend{%- endif %}
96

107
from src.api import routes
11-
{%- if cookiecutter.use_celery == "yes" %}
12-
from src.api.deps import get_redis_client{%- endif %}
138
from src.core.config import settings
149
from src.db.session import add_postgresql_extension
1510

@@ -35,9 +30,6 @@
3530

3631
def on_startup() -> None:
3732
add_postgresql_extension()
38-
{%- if cookiecutter.use_celery == "yes" %}
39-
redis_client = get_redis_client()
40-
FastAPICache.init(RedisBackend(redis_client), prefix="fastapi-cache"){%- endif %}
4133
{%- if cookiecutter.use_sentry == "yes" %}
4234
if settings.SENTRY_DSN:
4335
sentry_sdk.init(

0 commit comments

Comments
 (0)