Skip to content

Commit 9e224f1

Browse files
feat: Add Task processor (#26)
* feat: Add Task processor - Port `flagsmith/flagsmith-task-processor` - Port `waitfordb` management command - Introduce PostgreSQL for tests - Bump Poetry from 2.0.1 to 2.1.1 --------- Co-authored-by: Matthew Elwell <[email protected]>
1 parent 5ad6414 commit 9e224f1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+3536
-44
lines changed

.github/workflows/python-test.yml

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ jobs:
1212
matrix:
1313
python-version: ["3.11", "3.12"]
1414

15+
services:
16+
postgres:
17+
image: postgres:15.5-alpine
18+
env:
19+
POSTGRES_PASSWORD: password
20+
POSTGRES_DB: flagsmith
21+
ports: ['5432:5432']
22+
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
23+
1524
steps:
1625
- uses: actions/checkout@v4
1726

@@ -23,13 +32,17 @@ jobs:
2332
run: pipx install poetry
2433

2534
- name: Install Dependencies
26-
run: poetry install --with dev
35+
env:
36+
opts: --with dev
37+
run: make install-packages
2738

2839
- name: Check for missing migrations
29-
run: poetry run python manage.py makemigrations --no-input --dry-run --check
40+
env:
41+
opts: --no-input --dry-run --check
42+
run: make django-make-migrations
3043

3144
- name: Check for new typing errors
32-
run: poetry run mypy .
45+
run: make typecheck
3346

3447
- name: Run Tests
35-
run: poetry run pytest
48+
run: make test

Makefile

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
POETRY_VERSION ?= 2.0.1
1+
.EXPORT_ALL_VARIABLES:
2+
3+
POETRY_VERSION ?= 2.1.1
4+
5+
COMPOSE_FILE ?= docker/docker-compose.local.yml
6+
COMPOSE_PROJECT_NAME ?= flagsmith-common
27

38
.PHONY: install-pip
49
install-pip:
@@ -10,11 +15,44 @@ install-poetry:
1015

1116
.PHONY: install-packages
1217
install-packages:
13-
poetry install --no-root $(opts)
18+
poetry install $(opts)
1419

1520
.PHONY: install
1621
install: install-pip install-poetry install-packages
1722

1823
.PHONY: lint
1924
lint:
2025
poetry run pre-commit run -a
26+
27+
.PHONY: docker-up
28+
docker-up:
29+
docker compose up --force-recreate --remove-orphans -d
30+
docker compose ps
31+
32+
.PHONY: docker-down
33+
docker-down:
34+
docker compose down
35+
36+
.PHONY: test
37+
test:
38+
poetry run pytest $(opts)
39+
40+
.PHONY: typecheck
41+
typecheck:
42+
poetry run mypy .
43+
44+
.PHONY: django-make-migrations
45+
django-make-migrations:
46+
poetry run python manage.py waitfordb
47+
poetry run python manage.py makemigrations $(opts)
48+
49+
.PHONY: django-squash-migrations
50+
django-squash-migrations:
51+
poetry run python manage.py waitfordb
52+
poetry run python manage.py squashmigrations $(opts)
53+
54+
.PHONY: django-migrate
55+
django-migrate:
56+
poetry run python manage.py waitfordb
57+
poetry run python manage.py migrate
58+
poetry run python manage.py createcachetable

docker/docker-compose.local.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# A Compose file with minimal dependencies to be able to run Flagsmith, including its test suite, locally (not in Docker).
2+
3+
name: flagsmith
4+
5+
volumes:
6+
pg_data:
7+
8+
services:
9+
db:
10+
image: postgres:15.5-alpine
11+
pull_policy: always
12+
restart: unless-stopped
13+
volumes:
14+
- pg_data:/var/lib/postgresql/data
15+
ports:
16+
- 5432:5432
17+
environment:
18+
POSTGRES_DB: flagsmith
19+
POSTGRES_PASSWORD: password

poetry.lock

Lines changed: 254 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,19 @@ version = "1.5.2"
44
description = "Flagsmith's common library"
55
requires-python = ">=3.11,<4.0"
66
dependencies = [
7-
"django (<5)",
7+
"backoff (>=2.2.1,<3.0.0)",
8+
"django (>4,<5)",
89
"django-health-check",
910
"djangorestframework-recursive",
1011
"djangorestframework",
1112
"drf-writable-nested",
13+
"drf-yasg (>=1.21.10,<2.0.0)",
14+
"environs (<15)",
1215
"flagsmith-flag-engine",
1316
"gunicorn (>=19.1)",
1417
"prometheus-client (>=0.0.16)",
15-
"environs (<15)",
18+
"psycopg2 (>=2,<3)",
19+
"simplejson (>=3,<4)",
1620
]
1721
authors = [
1822
{ name = "Matthew Elwell" },
@@ -29,6 +33,7 @@ readme = "README.md"
2933
dynamic = ["classifiers"]
3034

3135
[project.urls]
36+
Changelog = "https://github.com/flagsmith/flagsmith-common/blob/main/CHANGELOG.md"
3237
Download = "https://github.com/flagsmith/flagsmith-common/releases"
3338
Homepage = "https://flagsmith.com"
3439
Issues = "https://github.com/flagsmith/flagsmith-common/issues"
@@ -48,9 +53,13 @@ classifiers = [
4853
"Intended Audience :: Developers",
4954
"License :: OSI Approved :: BSD License",
5055
]
51-
packages = [{ include = "common", from = "src" }]
56+
packages = [
57+
{ include = "common", from = "src" },
58+
{ include = "task_processor", from = "src" },
59+
]
5260

5361
[tool.poetry.group.dev.dependencies]
62+
dj-database-url = "^2.3.0"
5463
django-stubs = "^5.1.3"
5564
djangorestframework-stubs = "^3.15.3"
5665
mypy = "^1.15.0"
@@ -65,6 +74,7 @@ pytest-mock = "^3.14.0"
6574
requests = "^2.32.3"
6675
ruff = "*"
6776
setuptools = "^77.0.3"
77+
types-simplejson = "^3.20.0.20250326"
6878

6979
[build-system]
7080
requires = ["poetry-core"]

settings/dev.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1+
from datetime import time, timedelta
2+
3+
import dj_database_url
14
import prometheus_client
5+
from environs import Env
6+
7+
from task_processor.task_run_method import TaskRunMethod
8+
9+
env = Env()
210

311
# Settings expected by `mypy_django_plugin`
412
AWS_SES_REGION_ENDPOINT: str
@@ -8,15 +16,18 @@
816

917
# Settings required for tests
1018
DATABASES = {
11-
"default": {
12-
"ENGINE": "django.db.backends.sqlite3",
13-
"NAME": "common.sqlite3",
14-
}
19+
"default": dj_database_url.parse(
20+
env(
21+
"DATABASE_URL",
22+
default="postgresql://postgres:password@localhost:5432/flagsmith",
23+
)
24+
)
1525
}
1626
INSTALLED_APPS = [
1727
"django.contrib.auth",
1828
"django.contrib.contenttypes",
1929
"common.core",
30+
"task_processor",
2031
]
2132
MIDDLEWARE = [
2233
"common.gunicorn.middleware.RouteLoggerMiddleware",
@@ -26,3 +37,16 @@
2637
ROOT_URLCONF = "common.core.urls"
2738
TIME_ZONE = "UTC"
2839
USE_TZ = True
40+
41+
ENABLE_CLEAN_UP_OLD_TASKS = True
42+
ENABLE_TASK_PROCESSOR_HEALTH_CHECK = True
43+
RECURRING_TASK_RUN_RETENTION_DAYS = 15
44+
TASK_DELETE_BATCH_SIZE = 2000
45+
TASK_DELETE_INCLUDE_FAILED_TASKS = False
46+
TASK_DELETE_RETENTION_DAYS = 15
47+
TASK_DELETE_RUN_EVERY = timedelta(days=1)
48+
TASK_DELETE_RUN_TIME = time(5, 0, 0)
49+
TASK_RUN_METHOD = TaskRunMethod.TASK_PROCESSOR
50+
51+
# Avoid models.W042 warnings
52+
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"

src/common/core/main.py

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,80 @@
1+
import contextlib
12
import logging
23
import os
34
import sys
45
import tempfile
6+
import typing
57

68
from django.core.management import execute_from_command_line
79

810
logger = logging.getLogger(__name__)
911

1012

11-
def main() -> None:
13+
@contextlib.contextmanager
14+
def ensure_cli_env() -> typing.Generator[None, None, None]:
1215
"""
13-
The main entry point to the Flagsmith application.
16+
Set up the environment for the main entry point of the application
17+
and clean up after it's done.
1418
15-
An equivalent to Django's `manage.py` script, this module is used to run management commands.
19+
Add environment-related code that needs to happen before and after Django is involved
20+
to here.
1621
17-
It's installed as the `flagsmith` command.
22+
Use as a context manager, e.g.:
1823
19-
Everything that needs to be run before Django is started should be done here.
24+
```python
25+
with ensure_cli_env():
26+
main()
27+
```
28+
"""
29+
ctx = contextlib.ExitStack()
2030

21-
The end goal is to eventually replace Core API's `run-docker.sh` with this.
31+
# TODO @khvn26 Move logging setup to here
2232

23-
Usage:
24-
`flagsmith <command> [options]`
25-
"""
33+
# Currently we don't install Flagsmith modules as a package, so we need to add
34+
# $CWD to the Python path to be able to import them
35+
sys.path.append(os.getcwd())
36+
37+
# TODO @khvn26 We should find a better way to pre-set the Django settings module
38+
# without resorting to it being set outside of the application
2639
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.dev")
2740

2841
# Set up Prometheus' multiprocess mode
2942
if "PROMETHEUS_MULTIPROC_DIR" not in os.environ:
30-
prometheus_multiproc_dir = tempfile.TemporaryDirectory(
31-
prefix="prometheus_multiproc",
43+
prometheus_multiproc_dir_name = ctx.enter_context(
44+
tempfile.TemporaryDirectory(
45+
prefix="prometheus_multiproc",
46+
)
3247
)
48+
3349
logger.info(
3450
"Created %s for Prometheus multi-process mode",
35-
prometheus_multiproc_dir.name,
51+
prometheus_multiproc_dir_name,
3652
)
37-
os.environ["PROMETHEUS_MULTIPROC_DIR"] = prometheus_multiproc_dir.name
53+
os.environ["PROMETHEUS_MULTIPROC_DIR"] = prometheus_multiproc_dir_name
54+
55+
if "task-processor" in sys.argv:
56+
# A hacky way to signal we're not running the API
57+
os.environ["RUN_BY_PROCESSOR"] = "true"
58+
59+
with ctx:
60+
yield
61+
3862

39-
# Run Django
40-
execute_from_command_line(sys.argv)
63+
def main() -> None:
64+
"""
65+
The main entry point to the Flagsmith application.
66+
67+
An equivalent to Django's `manage.py` script, this module is used to run management commands.
68+
69+
It's installed as the `flagsmith` command.
70+
71+
Everything that needs to be run before Django is started should be done here.
72+
73+
The end goal is to eventually replace Core API's `run-docker.sh` with this.
74+
75+
Usage:
76+
`flagsmith <command> [options]`
77+
"""
78+
with ensure_cli_env():
79+
# Run Django
80+
execute_from_command_line(sys.argv)

src/common/core/management/commands/start.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
from django.core.management import BaseCommand, CommandParser
44
from django.utils.module_loading import autodiscover_modules
55

6-
from common.gunicorn.utils import add_arguments, run_server
6+
from common.gunicorn.utils import add_arguments as add_gunicorn_arguments
7+
from common.gunicorn.utils import run_server
8+
from task_processor.utils import add_arguments as add_task_processor_arguments
9+
from task_processor.utils import start_task_processor
710

811

912
class Command(BaseCommand):
@@ -13,20 +16,31 @@ def create_parser(self, *args: Any, **kwargs: Any) -> CommandParser:
1316
return super().create_parser(*args, conflict_handler="resolve", **kwargs)
1417

1518
def add_arguments(self, parser: CommandParser) -> None:
16-
add_arguments(parser)
19+
add_gunicorn_arguments(parser)
1720

1821
subparsers = parser.add_subparsers(
1922
title="sub-commands",
2023
required=True,
2124
)
25+
2226
api_parser = subparsers.add_parser(
2327
"api",
2428
help="Start the Core API.",
2529
)
2630
api_parser.set_defaults(handle_method=self.handle_api)
2731

32+
task_processor_parser = subparsers.add_parser(
33+
"task-processor",
34+
help="Start the Task Processor.",
35+
)
36+
task_processor_parser.set_defaults(handle_method=self.handle_task_processor)
37+
add_task_processor_arguments(task_processor_parser)
38+
2839
def initialise(self) -> None:
29-
autodiscover_modules("metrics")
40+
autodiscover_modules(
41+
"metrics",
42+
"tasks",
43+
)
3044

3145
def handle(
3246
self,
@@ -39,3 +53,9 @@ def handle(
3953

4054
def handle_api(self, *args: Any, **options: Any) -> None:
4155
run_server(options)
56+
57+
def handle_task_processor(self, *args: Any, **options: Any) -> None:
58+
with start_task_processor(options):
59+
# Delegate signal handling to Gunicorn.
60+
# The task processor will finalise once Gunicorn is shut down.
61+
run_server(options)

0 commit comments

Comments
 (0)