flagsmith-common 2.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- common/__init__.py +0 -0
- common/core/__init__.py +6 -0
- common/core/app.py +6 -0
- common/core/cli/__init__.py +0 -0
- common/core/cli/healthcheck.py +120 -0
- common/core/logging.py +24 -0
- common/core/main.py +105 -0
- common/core/management/__init__.py +0 -0
- common/core/management/commands/__init__.py +0 -0
- common/core/management/commands/docgen.py +63 -0
- common/core/management/commands/start.py +61 -0
- common/core/management/commands/waitfordb.py +87 -0
- common/core/metrics.py +25 -0
- common/core/middleware.py +22 -0
- common/core/templates/docgen-metrics.md +22 -0
- common/core/urls.py +17 -0
- common/core/utils.py +239 -0
- common/core/views.py +27 -0
- common/environments/permissions.py +15 -0
- common/features/__init__.py +0 -0
- common/features/multivariate/__init__.py +0 -0
- common/features/multivariate/serializers.py +19 -0
- common/features/serializers.py +68 -0
- common/features/versioning/__init__.py +0 -0
- common/features/versioning/serializers.py +13 -0
- common/gunicorn/__init__.py +0 -0
- common/gunicorn/conf.py +18 -0
- common/gunicorn/constants.py +23 -0
- common/gunicorn/logging.py +120 -0
- common/gunicorn/metrics.py +26 -0
- common/gunicorn/middleware.py +30 -0
- common/gunicorn/utils.py +104 -0
- common/migrations/__init__.py +0 -0
- common/migrations/helpers/__init__.py +9 -0
- common/migrations/helpers/postgres_helpers.py +41 -0
- common/organisations/permissions.py +10 -0
- common/projects/permissions.py +40 -0
- common/prometheus/__init__.py +3 -0
- common/prometheus/utils.py +38 -0
- common/py.typed +0 -0
- common/test_tools/__init__.py +11 -0
- common/test_tools/plugin.py +139 -0
- common/test_tools/types.py +56 -0
- common/test_tools/utils.py +11 -0
- common/types.py +45 -0
- flagsmith_common-2.2.4.dist-info/METADATA +196 -0
- flagsmith_common-2.2.4.dist-info/RECORD +92 -0
- flagsmith_common-2.2.4.dist-info/WHEEL +4 -0
- flagsmith_common-2.2.4.dist-info/entry_points.txt +6 -0
- flagsmith_common-2.2.4.dist-info/licenses/LICENSE +28 -0
- task_processor/__init__.py +0 -0
- task_processor/admin.py +38 -0
- task_processor/apps.py +47 -0
- task_processor/decorators.py +209 -0
- task_processor/exceptions.py +28 -0
- task_processor/health.py +44 -0
- task_processor/managers.py +18 -0
- task_processor/metrics.py +22 -0
- task_processor/migrations/0001_initial.py +44 -0
- task_processor/migrations/0002_healthcheckmodel.py +21 -0
- task_processor/migrations/0003_add_completed_to_task.py +22 -0
- task_processor/migrations/0004_recreate_task_indexes.py +43 -0
- task_processor/migrations/0005_update_conditional_index_conditions.py +45 -0
- task_processor/migrations/0006_auto_20230221_0802.py +45 -0
- task_processor/migrations/0007_add_is_locked.py +23 -0
- task_processor/migrations/0008_add_get_task_to_process_function.py +31 -0
- task_processor/migrations/0009_add_recurring_task_run_first_run_at.py +18 -0
- task_processor/migrations/0010_task_priority.py +27 -0
- task_processor/migrations/0011_add_priority_to_get_tasks_to_process.py +27 -0
- task_processor/migrations/0012_add_locked_at_and_timeout.py +40 -0
- task_processor/migrations/0013_add_last_picked_at.py +34 -0
- task_processor/migrations/__init__.py +0 -0
- task_processor/migrations/sql/0008_get_recurring_tasks_to_process.sql +30 -0
- task_processor/migrations/sql/0008_get_tasks_to_process.sql +30 -0
- task_processor/migrations/sql/0011_get_tasks_to_process.sql +30 -0
- task_processor/migrations/sql/0012_get_recurringtasks_to_process.sql +33 -0
- task_processor/migrations/sql/0013_get_recurringtasks_to_process.sql +33 -0
- task_processor/migrations/sql/__init__.py +0 -0
- task_processor/models.py +237 -0
- task_processor/monitoring.py +12 -0
- task_processor/processor.py +202 -0
- task_processor/py.typed +0 -0
- task_processor/routers.py +55 -0
- task_processor/serializers.py +7 -0
- task_processor/task_registry.py +90 -0
- task_processor/task_run_method.py +7 -0
- task_processor/tasks.py +71 -0
- task_processor/threads.py +128 -0
- task_processor/types.py +18 -0
- task_processor/urls.py +5 -0
- task_processor/utils.py +71 -0
- task_processor/views.py +20 -0
task_processor/tasks.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import typing
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.db.models import Q
|
|
7
|
+
from django.utils import timezone
|
|
8
|
+
|
|
9
|
+
from task_processor.decorators import (
|
|
10
|
+
register_recurring_task,
|
|
11
|
+
register_task_handler,
|
|
12
|
+
)
|
|
13
|
+
from task_processor.models import HealthCheckModel, RecurringTaskRun, Task
|
|
14
|
+
|
|
15
|
+
if typing.TYPE_CHECKING:
|
|
16
|
+
# ugh https://github.com/typeddjango/django-stubs/issues/1744
|
|
17
|
+
# TODO maybe switch to https://github.com/getsentry/sentry-forked-django-stubs
|
|
18
|
+
HealthCheckModel.objects = HealthCheckModel._default_manager
|
|
19
|
+
RecurringTaskRun.objects = RecurringTaskRun._default_manager
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@register_task_handler()
|
|
26
|
+
def create_health_check_model(health_check_model_uuid: str) -> None:
|
|
27
|
+
logger.info("Creating health check model.")
|
|
28
|
+
HealthCheckModel.objects.create(uuid=health_check_model_uuid)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@register_recurring_task(
|
|
32
|
+
run_every=settings.TASK_DELETE_RUN_EVERY,
|
|
33
|
+
first_run_time=settings.TASK_DELETE_RUN_TIME,
|
|
34
|
+
)
|
|
35
|
+
def clean_up_old_tasks() -> None:
|
|
36
|
+
if not settings.ENABLE_CLEAN_UP_OLD_TASKS:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
now = timezone.now()
|
|
40
|
+
delete_before = now - timedelta(days=settings.TASK_DELETE_RETENTION_DAYS)
|
|
41
|
+
|
|
42
|
+
# build the query
|
|
43
|
+
query = Q(completed=True)
|
|
44
|
+
if settings.TASK_DELETE_INCLUDE_FAILED_TASKS:
|
|
45
|
+
query = query | Q(num_failures__gte=3)
|
|
46
|
+
query = Q(scheduled_for__lt=delete_before) & query
|
|
47
|
+
|
|
48
|
+
# TODO: validate if deleting in batches is more / less impactful on the DB
|
|
49
|
+
while True:
|
|
50
|
+
# delete in batches of settings.TASK_DELETE_BATCH_SIZE
|
|
51
|
+
num_tasks_deleted, _ = Task.objects.filter(
|
|
52
|
+
pk__in=Task.objects.filter(query).values_list("id", flat=True)[
|
|
53
|
+
0 : settings.TASK_DELETE_BATCH_SIZE # noqa:E203
|
|
54
|
+
]
|
|
55
|
+
).delete()
|
|
56
|
+
if num_tasks_deleted == 0:
|
|
57
|
+
break
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@register_recurring_task(
|
|
61
|
+
run_every=settings.TASK_DELETE_RUN_EVERY,
|
|
62
|
+
first_run_time=settings.TASK_DELETE_RUN_TIME,
|
|
63
|
+
)
|
|
64
|
+
def clean_up_old_recurring_task_runs() -> None:
|
|
65
|
+
if not settings.ENABLE_CLEAN_UP_OLD_TASKS:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
now = timezone.now()
|
|
69
|
+
delete_before = now - timedelta(days=settings.RECURRING_TASK_RUN_RETENTION_DAYS)
|
|
70
|
+
|
|
71
|
+
RecurringTaskRun.objects.filter(finished_at__lt=delete_before).delete()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
import typing
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from threading import Thread
|
|
6
|
+
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.db import close_old_connections
|
|
9
|
+
from django.utils import timezone
|
|
10
|
+
|
|
11
|
+
from task_processor.processor import run_recurring_tasks, run_tasks
|
|
12
|
+
from task_processor.task_registry import initialise
|
|
13
|
+
from task_processor.types import TaskProcessorConfig
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TaskRunnerCoordinator(Thread):
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*args: typing.Any,
|
|
22
|
+
config: TaskProcessorConfig,
|
|
23
|
+
**kwargs: typing.Any,
|
|
24
|
+
) -> None:
|
|
25
|
+
super().__init__(*args, **kwargs)
|
|
26
|
+
self.config = config
|
|
27
|
+
self._threads: list[TaskRunner] = []
|
|
28
|
+
self._monitor_threads = True
|
|
29
|
+
|
|
30
|
+
def run(self) -> None:
|
|
31
|
+
initialise()
|
|
32
|
+
|
|
33
|
+
logger.info("Processor starting")
|
|
34
|
+
|
|
35
|
+
for _ in range(self.config.num_threads):
|
|
36
|
+
self._threads.append(
|
|
37
|
+
task := TaskRunner(
|
|
38
|
+
sleep_interval_millis=self.config.sleep_interval_ms,
|
|
39
|
+
queue_pop_size=self.config.queue_pop_size,
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
task.start()
|
|
43
|
+
|
|
44
|
+
ms_before_unhealthy = (
|
|
45
|
+
self.config.grace_period_ms + self.config.sleep_interval_ms
|
|
46
|
+
)
|
|
47
|
+
while self._monitor_threads:
|
|
48
|
+
time.sleep(1)
|
|
49
|
+
unhealthy_threads = self._get_unhealthy_threads(
|
|
50
|
+
ms_before_unhealthy=ms_before_unhealthy
|
|
51
|
+
)
|
|
52
|
+
if unhealthy_threads:
|
|
53
|
+
logger.warning("%d unhealthy threads detected", len(unhealthy_threads))
|
|
54
|
+
|
|
55
|
+
for thread in self._threads:
|
|
56
|
+
thread.join()
|
|
57
|
+
|
|
58
|
+
def _get_unhealthy_threads(self, ms_before_unhealthy: int) -> list["TaskRunner"]:
|
|
59
|
+
unhealthy_threads = []
|
|
60
|
+
healthy_threshold = timezone.now() - timedelta(milliseconds=ms_before_unhealthy)
|
|
61
|
+
|
|
62
|
+
for thread in self._threads:
|
|
63
|
+
if (
|
|
64
|
+
not thread.is_alive()
|
|
65
|
+
or not thread.last_checked_for_tasks
|
|
66
|
+
or thread.last_checked_for_tasks < healthy_threshold
|
|
67
|
+
):
|
|
68
|
+
unhealthy_threads.append(thread)
|
|
69
|
+
return unhealthy_threads
|
|
70
|
+
|
|
71
|
+
def stop(self) -> None:
|
|
72
|
+
self._monitor_threads = False
|
|
73
|
+
for t in self._threads:
|
|
74
|
+
t.stop()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TaskRunner(Thread):
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
*args: typing.Any,
|
|
81
|
+
sleep_interval_millis: int = 2000,
|
|
82
|
+
queue_pop_size: int = 1,
|
|
83
|
+
**kwargs: typing.Any,
|
|
84
|
+
):
|
|
85
|
+
super(TaskRunner, self).__init__(*args, **kwargs)
|
|
86
|
+
self.sleep_interval_millis = sleep_interval_millis
|
|
87
|
+
self.queue_pop_size = queue_pop_size
|
|
88
|
+
self.last_checked_for_tasks: datetime | None = None
|
|
89
|
+
|
|
90
|
+
self._stopped = False
|
|
91
|
+
|
|
92
|
+
def run(self) -> None:
|
|
93
|
+
while not self._stopped:
|
|
94
|
+
self.last_checked_for_tasks = timezone.now()
|
|
95
|
+
self.run_iteration()
|
|
96
|
+
time.sleep(self.sleep_interval_millis / 1000)
|
|
97
|
+
|
|
98
|
+
def run_iteration(self) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Consume and execute tasks from the queue, and run recurring tasks
|
|
101
|
+
|
|
102
|
+
This method tries to consume tasks from multiple databases as to ensure
|
|
103
|
+
that any remaining tasks are processed after opting in or out of a
|
|
104
|
+
separate database setup.
|
|
105
|
+
"""
|
|
106
|
+
database_is_separate = "task_processor" in settings.TASK_PROCESSOR_DATABASES
|
|
107
|
+
|
|
108
|
+
for database in settings.TASK_PROCESSOR_DATABASES:
|
|
109
|
+
try:
|
|
110
|
+
run_tasks(database, self.queue_pop_size)
|
|
111
|
+
|
|
112
|
+
# Recurring tasks are only run on one database
|
|
113
|
+
if (database == "default") ^ database_is_separate:
|
|
114
|
+
run_recurring_tasks(database)
|
|
115
|
+
except Exception as exception:
|
|
116
|
+
# To prevent task threads from dying if they get an error retrieving the tasks from the
|
|
117
|
+
# database this will allow the thread to continue trying to retrieve tasks if it can
|
|
118
|
+
# successfully re-establish a connection to the database.
|
|
119
|
+
exception_repr = f"{exception.__class__.__module__}.{repr(exception)}"
|
|
120
|
+
logger.error(
|
|
121
|
+
f"Error handling tasks from database '{database}': {exception_repr}",
|
|
122
|
+
exc_info=exception,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
close_old_connections()
|
|
126
|
+
|
|
127
|
+
def stop(self) -> None:
|
|
128
|
+
self._stopped = True
|
task_processor/types.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Callable, ParamSpec, TypeAlias, TypedDict
|
|
3
|
+
|
|
4
|
+
TaskParameters = ParamSpec("TaskParameters")
|
|
5
|
+
|
|
6
|
+
TaskCallable: TypeAlias = Callable[TaskParameters, None]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class TaskProcessorConfig:
|
|
11
|
+
num_threads: int
|
|
12
|
+
sleep_interval_ms: int
|
|
13
|
+
grace_period_ms: int
|
|
14
|
+
queue_pop_size: int
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MonitoringInfo(TypedDict):
|
|
18
|
+
waiting: int
|
task_processor/urls.py
ADDED
task_processor/utils.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import Any, Generator
|
|
6
|
+
|
|
7
|
+
from task_processor.threads import TaskRunnerCoordinator
|
|
8
|
+
from task_processor.types import TaskCallable, TaskProcessorConfig
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_task_identifier_from_function(
|
|
14
|
+
function: TaskCallable[Any],
|
|
15
|
+
task_name: str | None,
|
|
16
|
+
) -> str:
|
|
17
|
+
module = inspect.getmodule(function)
|
|
18
|
+
assert module
|
|
19
|
+
return f"{module.__name__.rsplit('.')[-1]}.{task_name or function.__name__}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--numthreads",
|
|
25
|
+
type=int,
|
|
26
|
+
help="Number of worker threads to run.",
|
|
27
|
+
default=5,
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--sleepintervalms",
|
|
31
|
+
type=int,
|
|
32
|
+
help="Number of millis each worker waits before checking for new tasks",
|
|
33
|
+
default=2000,
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--graceperiodms",
|
|
37
|
+
type=int,
|
|
38
|
+
help="Number of millis before running task is considered 'stuck'.",
|
|
39
|
+
default=20000,
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--queuepopsize",
|
|
43
|
+
type=int,
|
|
44
|
+
help="Number of tasks each worker will pop from the queue on each cycle.",
|
|
45
|
+
default=10,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@contextmanager
|
|
50
|
+
def start_task_processor(
|
|
51
|
+
options: dict[str, Any],
|
|
52
|
+
) -> Generator[
|
|
53
|
+
TaskRunnerCoordinator,
|
|
54
|
+
None,
|
|
55
|
+
None,
|
|
56
|
+
]:
|
|
57
|
+
config = TaskProcessorConfig(
|
|
58
|
+
num_threads=options["numthreads"],
|
|
59
|
+
sleep_interval_ms=options["sleepintervalms"],
|
|
60
|
+
grace_period_ms=options["graceperiodms"],
|
|
61
|
+
queue_pop_size=options["queuepopsize"],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
logger.debug("Config: %s", config)
|
|
65
|
+
|
|
66
|
+
coordinator = TaskRunnerCoordinator(config=config)
|
|
67
|
+
coordinator.start()
|
|
68
|
+
try:
|
|
69
|
+
yield coordinator
|
|
70
|
+
finally:
|
|
71
|
+
coordinator.stop()
|
task_processor/views.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from drf_yasg.utils import swagger_auto_schema # type: ignore[import-untyped]
|
|
4
|
+
from rest_framework.decorators import api_view, permission_classes
|
|
5
|
+
from rest_framework.permissions import IsAdminUser, IsAuthenticated
|
|
6
|
+
from rest_framework.request import Request
|
|
7
|
+
from rest_framework.response import Response
|
|
8
|
+
|
|
9
|
+
from task_processor.monitoring import get_num_waiting_tasks
|
|
10
|
+
from task_processor.serializers import MonitoringSerializer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@swagger_auto_schema(method="GET", responses={200: MonitoringSerializer()}) # type: ignore[misc]
|
|
14
|
+
@api_view(http_method_names=["GET"])
|
|
15
|
+
@permission_classes([IsAuthenticated, IsAdminUser])
|
|
16
|
+
def monitoring(request: Request, /, **kwargs: Any) -> Response:
|
|
17
|
+
return Response(
|
|
18
|
+
data={"waiting": get_num_waiting_tasks()},
|
|
19
|
+
content_type="application/json",
|
|
20
|
+
)
|