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
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
CREATE OR REPLACE FUNCTION get_recurringtasks_to_process()
|
|
2
|
+
RETURNS SETOF task_processor_recurringtask AS $$
|
|
3
|
+
DECLARE
|
|
4
|
+
row_to_return task_processor_recurringtask;
|
|
5
|
+
BEGIN
|
|
6
|
+
-- Select the tasks that needs to be processed
|
|
7
|
+
FOR row_to_return IN
|
|
8
|
+
SELECT *
|
|
9
|
+
FROM task_processor_recurringtask
|
|
10
|
+
-- Add one minute to the timeout as a grace period for overhead
|
|
11
|
+
WHERE is_locked = FALSE OR (locked_at IS NOT NULL AND locked_at < NOW() - timeout + INTERVAL '1 minute')
|
|
12
|
+
ORDER BY last_picked_at NULLS FIRST
|
|
13
|
+
LIMIT 1
|
|
14
|
+
-- Select for update to ensure that no other workers can select these tasks while in this transaction block
|
|
15
|
+
FOR UPDATE SKIP LOCKED
|
|
16
|
+
LOOP
|
|
17
|
+
-- Lock every selected task(by updating `is_locked` to true)
|
|
18
|
+
UPDATE task_processor_recurringtask
|
|
19
|
+
-- Lock this row by setting is_locked True, so that no other workers can select these tasks after this
|
|
20
|
+
-- transaction is complete (but the tasks are still being executed by the current worker)
|
|
21
|
+
SET is_locked = TRUE, locked_at = NOW(), last_picked_at = NOW()
|
|
22
|
+
WHERE id = row_to_return.id;
|
|
23
|
+
-- If we don't explicitly update the columns here, the client will receive a row
|
|
24
|
+
-- that is locked but still shows `is_locked` as `False` and `locked_at` as `None`.
|
|
25
|
+
row_to_return.is_locked := TRUE;
|
|
26
|
+
row_to_return.locked_at := NOW();
|
|
27
|
+
RETURN NEXT row_to_return;
|
|
28
|
+
END LOOP;
|
|
29
|
+
|
|
30
|
+
RETURN;
|
|
31
|
+
END;
|
|
32
|
+
$$ LANGUAGE plpgsql
|
|
33
|
+
|
|
File without changes
|
task_processor/models.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
import uuid
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
|
|
5
|
+
import simplejson as json
|
|
6
|
+
from django.core.serializers.json import DjangoJSONEncoder
|
|
7
|
+
from django.db import models
|
|
8
|
+
from django.utils import timezone
|
|
9
|
+
|
|
10
|
+
from task_processor.exceptions import TaskQueueFullError
|
|
11
|
+
from task_processor.managers import RecurringTaskManager, TaskManager
|
|
12
|
+
from task_processor.task_registry import get_task, registered_tasks
|
|
13
|
+
from task_processor.types import TaskCallable
|
|
14
|
+
|
|
15
|
+
_django_json_encoder_default = DjangoJSONEncoder().default
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TaskPriority(models.IntegerChoices):
|
|
19
|
+
LOWER = 100
|
|
20
|
+
LOW = 75
|
|
21
|
+
NORMAL = 50
|
|
22
|
+
HIGH = 25
|
|
23
|
+
HIGHEST = 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AbstractBaseTask(models.Model):
|
|
27
|
+
uuid = models.UUIDField(unique=True, default=uuid.uuid4)
|
|
28
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
29
|
+
task_identifier = models.CharField(max_length=200)
|
|
30
|
+
serialized_args = models.TextField(blank=True, null=True)
|
|
31
|
+
serialized_kwargs = models.TextField(blank=True, null=True)
|
|
32
|
+
is_locked = models.BooleanField(default=False)
|
|
33
|
+
timeout = models.DurationField(blank=True, null=True)
|
|
34
|
+
|
|
35
|
+
class Meta:
|
|
36
|
+
abstract = True
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def args(self) -> tuple[typing.Any, ...]:
|
|
40
|
+
if self.serialized_args:
|
|
41
|
+
args = self.deserialize_data(self.serialized_args)
|
|
42
|
+
return tuple(args)
|
|
43
|
+
return ()
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def kwargs(self) -> typing.Dict[str, typing.Any]:
|
|
47
|
+
if self.serialized_kwargs:
|
|
48
|
+
kwargs = self.deserialize_data(self.serialized_kwargs)
|
|
49
|
+
if typing.TYPE_CHECKING:
|
|
50
|
+
assert isinstance(kwargs, dict)
|
|
51
|
+
return kwargs
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def serialize_data(data: typing.Any) -> str:
|
|
56
|
+
return json.dumps(data, default=_django_json_encoder_default)
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def deserialize_data(data: str) -> typing.Any:
|
|
60
|
+
return json.loads(data)
|
|
61
|
+
|
|
62
|
+
def mark_failure(self) -> None:
|
|
63
|
+
self.unlock()
|
|
64
|
+
|
|
65
|
+
def mark_success(self) -> None:
|
|
66
|
+
self.unlock()
|
|
67
|
+
|
|
68
|
+
def unlock(self) -> None:
|
|
69
|
+
self.is_locked = False
|
|
70
|
+
|
|
71
|
+
def run(self) -> None:
|
|
72
|
+
return self.callable(*self.args, **self.kwargs)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def callable(self) -> TaskCallable[typing.Any]:
|
|
76
|
+
task = get_task(self.task_identifier)
|
|
77
|
+
return task.task_function
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Task(AbstractBaseTask):
|
|
81
|
+
scheduled_for = models.DateTimeField(blank=True, null=True, default=timezone.now)
|
|
82
|
+
|
|
83
|
+
timeout = models.DurationField(blank=True, null=True)
|
|
84
|
+
|
|
85
|
+
# denormalise failures and completion so that we can use select_for_update
|
|
86
|
+
num_failures = models.IntegerField(default=0)
|
|
87
|
+
completed = models.BooleanField(default=False)
|
|
88
|
+
objects: TaskManager = TaskManager()
|
|
89
|
+
priority = models.SmallIntegerField(
|
|
90
|
+
default=None, null=True, choices=TaskPriority.choices
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
class Meta:
|
|
94
|
+
# We have customised the migration in 0004 to only apply this change to postgres databases
|
|
95
|
+
# TODO: work out how to index the taskprocessor_task table for Oracle and MySQL
|
|
96
|
+
indexes = [
|
|
97
|
+
models.Index(
|
|
98
|
+
name="incomplete_tasks_idx",
|
|
99
|
+
fields=["scheduled_for"],
|
|
100
|
+
condition=models.Q(completed=False, num_failures__lt=3),
|
|
101
|
+
)
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def create(
|
|
106
|
+
cls,
|
|
107
|
+
task_identifier: str,
|
|
108
|
+
scheduled_for: datetime,
|
|
109
|
+
priority: TaskPriority = TaskPriority.NORMAL,
|
|
110
|
+
queue_size: int | None = None,
|
|
111
|
+
*,
|
|
112
|
+
args: typing.Tuple[typing.Any, ...] | None = None,
|
|
113
|
+
kwargs: typing.Dict[str, typing.Any] | None = None,
|
|
114
|
+
timeout: timedelta | None = timedelta(seconds=60),
|
|
115
|
+
) -> "Task":
|
|
116
|
+
if queue_size and cls._is_queue_full(task_identifier, queue_size):
|
|
117
|
+
raise TaskQueueFullError(
|
|
118
|
+
f"Queue for task {task_identifier} is full. "
|
|
119
|
+
f"Max queue size is {queue_size}"
|
|
120
|
+
)
|
|
121
|
+
return Task(
|
|
122
|
+
task_identifier=task_identifier,
|
|
123
|
+
scheduled_for=scheduled_for,
|
|
124
|
+
priority=priority,
|
|
125
|
+
serialized_args=cls.serialize_data(args or tuple()),
|
|
126
|
+
serialized_kwargs=cls.serialize_data(kwargs or dict()),
|
|
127
|
+
timeout=timeout,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def _is_queue_full(cls, task_identifier: str, queue_size: int) -> bool:
|
|
132
|
+
return (
|
|
133
|
+
cls.objects.filter(
|
|
134
|
+
task_identifier=task_identifier,
|
|
135
|
+
completed=False,
|
|
136
|
+
num_failures__lt=3,
|
|
137
|
+
).count()
|
|
138
|
+
> queue_size
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def mark_failure(self) -> None:
|
|
142
|
+
super().mark_failure()
|
|
143
|
+
self.num_failures += 1
|
|
144
|
+
|
|
145
|
+
def mark_success(self) -> None:
|
|
146
|
+
super().mark_success()
|
|
147
|
+
self.completed = True
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class RecurringTask(AbstractBaseTask):
|
|
151
|
+
run_every = models.DurationField()
|
|
152
|
+
first_run_time = models.TimeField(blank=True, null=True)
|
|
153
|
+
|
|
154
|
+
locked_at = models.DateTimeField(blank=True, null=True)
|
|
155
|
+
timeout = models.DurationField(default=timedelta(minutes=30))
|
|
156
|
+
|
|
157
|
+
last_picked_at = models.DateTimeField(blank=True, null=True)
|
|
158
|
+
objects: RecurringTaskManager = RecurringTaskManager()
|
|
159
|
+
|
|
160
|
+
class Meta:
|
|
161
|
+
constraints = [
|
|
162
|
+
models.UniqueConstraint(
|
|
163
|
+
fields=["task_identifier", "run_every"],
|
|
164
|
+
name="unique_run_every_tasks",
|
|
165
|
+
),
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
def unlock(self) -> None:
|
|
169
|
+
self.is_locked = False
|
|
170
|
+
self.locked_at = None
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def should_execute(self) -> bool:
|
|
174
|
+
now = timezone.now()
|
|
175
|
+
last_task_run = (
|
|
176
|
+
self.task_runs.order_by("-started_at").first() if self.pk else None
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if not last_task_run:
|
|
180
|
+
# If we have never run this task, then we should execute it only if
|
|
181
|
+
# the time has passed after which we want to ensure this task runs.
|
|
182
|
+
# This allows us to control when intensive tasks should be run.
|
|
183
|
+
return not (self.first_run_time and self.first_run_time > now.time())
|
|
184
|
+
|
|
185
|
+
# if the last run was at t- run_every, then we should execute it
|
|
186
|
+
if (timezone.now() - last_task_run.started_at) >= self.run_every:
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
# if the last run was not a success and we do not have
|
|
190
|
+
# more than 3 failures in t- run_every, then we should execute it
|
|
191
|
+
if (
|
|
192
|
+
last_task_run.result != TaskResult.SUCCESS.name
|
|
193
|
+
and self.task_runs.filter(started_at__gte=(now - self.run_every)).count()
|
|
194
|
+
<= 3
|
|
195
|
+
):
|
|
196
|
+
return True
|
|
197
|
+
# otherwise, we should not execute it
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def is_task_registered(self) -> bool:
|
|
202
|
+
return self.task_identifier in registered_tasks
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class TaskResult(models.Choices):
|
|
206
|
+
SUCCESS = "SUCCESS"
|
|
207
|
+
FAILURE = "FAILURE"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class AbstractTaskRun(models.Model):
|
|
211
|
+
started_at = models.DateTimeField()
|
|
212
|
+
finished_at = models.DateTimeField(blank=True, null=True)
|
|
213
|
+
result = models.CharField(
|
|
214
|
+
max_length=50, choices=TaskResult.choices, blank=True, null=True, db_index=True
|
|
215
|
+
)
|
|
216
|
+
error_details = models.TextField(blank=True, null=True)
|
|
217
|
+
task = models.ForeignKey(
|
|
218
|
+
AbstractBaseTask, on_delete=models.CASCADE, related_name="task_runs"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
class Meta:
|
|
222
|
+
abstract = True
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class TaskRun(AbstractTaskRun):
|
|
226
|
+
task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name="task_runs")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class RecurringTaskRun(AbstractTaskRun):
|
|
230
|
+
task = models.ForeignKey(
|
|
231
|
+
RecurringTask, on_delete=models.CASCADE, related_name="task_runs"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class HealthCheckModel(models.Model):
|
|
236
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
237
|
+
uuid = models.UUIDField(unique=True, blank=False, null=False)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from django.utils import timezone
|
|
2
|
+
|
|
3
|
+
from task_processor.models import Task
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_num_waiting_tasks() -> int:
|
|
7
|
+
return Task.objects.filter(
|
|
8
|
+
num_failures__lt=3,
|
|
9
|
+
completed=False,
|
|
10
|
+
scheduled_for__lt=timezone.now(),
|
|
11
|
+
is_locked=False,
|
|
12
|
+
).count()
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import traceback
|
|
3
|
+
import typing
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from contextlib import ExitStack
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
from django.utils import timezone
|
|
10
|
+
|
|
11
|
+
from task_processor import metrics
|
|
12
|
+
from task_processor.exceptions import TaskBackoffError
|
|
13
|
+
from task_processor.managers import RecurringTaskManager, TaskManager
|
|
14
|
+
from task_processor.models import (
|
|
15
|
+
AbstractBaseTask,
|
|
16
|
+
RecurringTask,
|
|
17
|
+
RecurringTaskRun,
|
|
18
|
+
Task,
|
|
19
|
+
TaskResult,
|
|
20
|
+
TaskRun,
|
|
21
|
+
)
|
|
22
|
+
from task_processor.task_registry import TaskType, get_task
|
|
23
|
+
|
|
24
|
+
T = typing.TypeVar("T", bound=AbstractBaseTask)
|
|
25
|
+
AnyTaskRun = TaskRun | RecurringTaskRun
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
UNREGISTERED_RECURRING_TASK_GRACE_PERIOD = timedelta(minutes=30)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_tasks(database: str, num_tasks: int = 1) -> list[TaskRun]:
|
|
33
|
+
if num_tasks < 1:
|
|
34
|
+
raise ValueError("Number of tasks to process must be at least one")
|
|
35
|
+
|
|
36
|
+
task_manager: TaskManager = Task.objects.db_manager(database)
|
|
37
|
+
tasks = task_manager.get_tasks_to_process(num_tasks)
|
|
38
|
+
if tasks:
|
|
39
|
+
logger.debug(f"Running {len(tasks)} task(s) from database '{database}'")
|
|
40
|
+
|
|
41
|
+
executed_tasks = []
|
|
42
|
+
task_runs = []
|
|
43
|
+
|
|
44
|
+
for task in tasks:
|
|
45
|
+
task, task_run = _run_task(task)
|
|
46
|
+
|
|
47
|
+
executed_tasks.append(task)
|
|
48
|
+
assert isinstance(task_run, TaskRun)
|
|
49
|
+
task_runs.append(task_run)
|
|
50
|
+
|
|
51
|
+
if executed_tasks:
|
|
52
|
+
Task.objects.using(database).bulk_update(
|
|
53
|
+
executed_tasks,
|
|
54
|
+
fields=["completed", "num_failures", "is_locked", "scheduled_for"],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if task_runs:
|
|
58
|
+
TaskRun.objects.using(database).bulk_create(task_runs)
|
|
59
|
+
logger.debug(
|
|
60
|
+
f"Finished running {len(task_runs)} task(s) from database '{database}'"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return task_runs
|
|
64
|
+
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_recurring_tasks(database: str) -> list[RecurringTaskRun]:
|
|
69
|
+
# NOTE: We will probably see a lot of delay in the execution of recurring tasks
|
|
70
|
+
# if the tasks take longer then `run_every` to execute. This is not
|
|
71
|
+
# a problem for now, but we should be mindful of this limitation
|
|
72
|
+
task_manager: RecurringTaskManager = RecurringTask.objects.db_manager(database)
|
|
73
|
+
tasks = task_manager.get_tasks_to_process()
|
|
74
|
+
if tasks:
|
|
75
|
+
logger.debug(f"Running {len(tasks)} recurring task(s)")
|
|
76
|
+
|
|
77
|
+
task_runs = []
|
|
78
|
+
|
|
79
|
+
for task in tasks:
|
|
80
|
+
if not task.is_task_registered:
|
|
81
|
+
# This is necessary to ensure that old instances of the task processor,
|
|
82
|
+
# which may still be running during deployment, do not remove tasks added by new instances.
|
|
83
|
+
# Reference: https://github.com/Flagsmith/flagsmith/issues/2551
|
|
84
|
+
task_age = timezone.now() - task.created_at
|
|
85
|
+
if task_age > UNREGISTERED_RECURRING_TASK_GRACE_PERIOD:
|
|
86
|
+
task.delete(using=database)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
if task.should_execute:
|
|
90
|
+
task, task_run = _run_task(task)
|
|
91
|
+
assert isinstance(task_run, RecurringTaskRun)
|
|
92
|
+
task_runs.append(task_run)
|
|
93
|
+
else:
|
|
94
|
+
task.unlock()
|
|
95
|
+
|
|
96
|
+
# update all tasks that were not deleted
|
|
97
|
+
to_update = [task for task in tasks if task.id]
|
|
98
|
+
RecurringTask.objects.using(database).bulk_update(
|
|
99
|
+
to_update,
|
|
100
|
+
fields=["is_locked", "locked_at"],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if task_runs:
|
|
104
|
+
RecurringTaskRun.objects.using(database).bulk_create(task_runs)
|
|
105
|
+
logger.debug(f"Finished running {len(task_runs)} recurring task(s)")
|
|
106
|
+
|
|
107
|
+
return task_runs
|
|
108
|
+
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _run_task(
|
|
113
|
+
task: T,
|
|
114
|
+
) -> typing.Tuple[T, AnyTaskRun]:
|
|
115
|
+
assert settings.TASK_PROCESSOR_MODE, (
|
|
116
|
+
"Attempt to run tasks in a non-task-processor environment"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
ctx = ExitStack()
|
|
120
|
+
timer = metrics.flagsmith_task_processor_task_duration_seconds.time()
|
|
121
|
+
ctx.enter_context(timer)
|
|
122
|
+
|
|
123
|
+
task_identifier = task.task_identifier
|
|
124
|
+
registered_task = get_task(task_identifier)
|
|
125
|
+
|
|
126
|
+
logger.debug(
|
|
127
|
+
f"Running task {task_identifier} id={task.pk} args={task.args} kwargs={task.kwargs}"
|
|
128
|
+
)
|
|
129
|
+
task_run: AnyTaskRun = task.task_runs.model(started_at=timezone.now(), task=task) # type: ignore[attr-defined]
|
|
130
|
+
result: str
|
|
131
|
+
executor = None
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
# Use explicit executor management to avoid blocking on shutdown
|
|
135
|
+
# when tasks timeout but continue running in worker threads.
|
|
136
|
+
# The default context manager behavior (wait=True) would block
|
|
137
|
+
# the TaskRunner thread indefinitely waiting for stuck workers.
|
|
138
|
+
executor = ThreadPoolExecutor(max_workers=1)
|
|
139
|
+
future = executor.submit(task.run)
|
|
140
|
+
timeout = task.timeout.total_seconds() if task.timeout else None
|
|
141
|
+
future.result(timeout=timeout) # Wait for completion or timeout
|
|
142
|
+
|
|
143
|
+
task_run.result = result = TaskResult.SUCCESS.value
|
|
144
|
+
task_run.finished_at = timezone.now()
|
|
145
|
+
task.mark_success()
|
|
146
|
+
|
|
147
|
+
logger.debug(f"Task {task_identifier} id={task.pk} completed")
|
|
148
|
+
|
|
149
|
+
except Exception as e:
|
|
150
|
+
# For errors that don't include a default message (e.g., TimeoutError),
|
|
151
|
+
# fall back to using repr.
|
|
152
|
+
err_msg = str(e) or repr(e)
|
|
153
|
+
|
|
154
|
+
task.mark_failure()
|
|
155
|
+
|
|
156
|
+
task_run.result = result = TaskResult.FAILURE.value
|
|
157
|
+
task_run.error_details = str(traceback.format_exc())
|
|
158
|
+
|
|
159
|
+
logger.error(
|
|
160
|
+
"Failed to execute task '%s', with id %d. Exception: %s",
|
|
161
|
+
task_identifier,
|
|
162
|
+
task.pk,
|
|
163
|
+
err_msg,
|
|
164
|
+
exc_info=True,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if isinstance(e, TaskBackoffError):
|
|
168
|
+
assert registered_task.task_type == TaskType.STANDARD, (
|
|
169
|
+
"Attempt to back off a recurring task (currently not supported)"
|
|
170
|
+
)
|
|
171
|
+
if typing.TYPE_CHECKING:
|
|
172
|
+
assert isinstance(task, Task)
|
|
173
|
+
if task.num_failures <= 3:
|
|
174
|
+
delay_until = e.delay_until or timezone.now() + timedelta(
|
|
175
|
+
seconds=settings.TASK_BACKOFF_DEFAULT_DELAY_SECONDS,
|
|
176
|
+
)
|
|
177
|
+
task.scheduled_for = delay_until
|
|
178
|
+
logger.info(
|
|
179
|
+
"Backoff requested. Task '%s' set to retry at %s",
|
|
180
|
+
task_identifier,
|
|
181
|
+
delay_until,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
finally:
|
|
185
|
+
# Always shutdown the executor without waiting for worker threads.
|
|
186
|
+
# This prevents the TaskRunner thread from blocking indefinitely
|
|
187
|
+
# when a task times out but continues running in a worker thread.
|
|
188
|
+
if executor is not None:
|
|
189
|
+
executor.shutdown(wait=False)
|
|
190
|
+
|
|
191
|
+
labels = {
|
|
192
|
+
"task_identifier": task_identifier,
|
|
193
|
+
"task_type": registered_task.task_type.value.lower(),
|
|
194
|
+
"result": result.lower(),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
timer.labels(**labels) # type: ignore[no-untyped-call]
|
|
198
|
+
ctx.close()
|
|
199
|
+
|
|
200
|
+
metrics.flagsmith_task_processor_finished_tasks_total.labels(**labels).inc()
|
|
201
|
+
|
|
202
|
+
return task, task_run
|
task_processor/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from django.db.models import Model
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TaskProcessorRouter:
|
|
5
|
+
"""
|
|
6
|
+
Routing of database operations for task processor models
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
route_app_labels = ["task_processor"]
|
|
10
|
+
|
|
11
|
+
def db_for_read(self, model: type[Model], **hints: None) -> str | None:
|
|
12
|
+
if model._meta.app_label in self.route_app_labels:
|
|
13
|
+
return "task_processor"
|
|
14
|
+
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
def db_for_write(self, model: type[Model], **hints: None) -> str | None:
|
|
18
|
+
if model._meta.app_label in self.route_app_labels:
|
|
19
|
+
return "task_processor"
|
|
20
|
+
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
def allow_relation(self, obj1: Model, obj2: Model, **hints: None) -> bool | None:
|
|
24
|
+
both_objects_from_task_processor = (
|
|
25
|
+
obj1._meta.app_label in self.route_app_labels
|
|
26
|
+
and obj2._meta.app_label in self.route_app_labels
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if both_objects_from_task_processor:
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
def allow_migrate(
|
|
35
|
+
self,
|
|
36
|
+
db: str,
|
|
37
|
+
app_label: str,
|
|
38
|
+
**hints: None,
|
|
39
|
+
) -> bool | None:
|
|
40
|
+
"""
|
|
41
|
+
Allow migrations to hit BOTH databases
|
|
42
|
+
|
|
43
|
+
NOTE: We run migrations on both databases because:
|
|
44
|
+
|
|
45
|
+
- The `task_processor` separate database was only introduced later in
|
|
46
|
+
history, and migrating to it does not delete old data from `default`.
|
|
47
|
+
We'd rather keep data in `default` consistent across time rather than
|
|
48
|
+
leaving behind possibly inconsistent data.
|
|
49
|
+
- We want to make it easier to migrate to the new database, _or back_
|
|
50
|
+
to a single database setup if needed. Running DDL consistently helps.
|
|
51
|
+
"""
|
|
52
|
+
if app_label in self.route_app_labels:
|
|
53
|
+
return db in ["default", "task_processor"]
|
|
54
|
+
|
|
55
|
+
return None
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import logging
|
|
3
|
+
import typing
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from task_processor.exceptions import TaskProcessingError
|
|
7
|
+
from task_processor.types import TaskCallable
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TaskType(enum.Enum):
|
|
13
|
+
STANDARD = "STANDARD"
|
|
14
|
+
RECURRING = "RECURRING"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RegisteredTask:
|
|
19
|
+
task_identifier: str
|
|
20
|
+
task_function: TaskCallable[typing.Any]
|
|
21
|
+
task_type: TaskType = TaskType.STANDARD
|
|
22
|
+
task_kwargs: dict[str, typing.Any] | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
registered_tasks: dict[str, RegisteredTask] = {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def initialise() -> None:
|
|
29
|
+
global registered_tasks
|
|
30
|
+
|
|
31
|
+
from task_processor.models import RecurringTask
|
|
32
|
+
|
|
33
|
+
for task_identifier, registered_task in registered_tasks.items():
|
|
34
|
+
logger.debug("Initialising task '%s'", task_identifier)
|
|
35
|
+
|
|
36
|
+
if registered_task.task_type == TaskType.RECURRING:
|
|
37
|
+
logger.debug("Persisting recurring task '%s'", task_identifier)
|
|
38
|
+
RecurringTask.objects.update_or_create(
|
|
39
|
+
task_identifier=task_identifier,
|
|
40
|
+
defaults=registered_task.task_kwargs,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_task(task_identifier: str) -> RegisteredTask:
|
|
45
|
+
global registered_tasks
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
return registered_tasks[task_identifier]
|
|
49
|
+
except KeyError:
|
|
50
|
+
raise TaskProcessingError(
|
|
51
|
+
"No task registered with identifier '%s'. Ensure your task is "
|
|
52
|
+
"decorated with @register_task_handler.",
|
|
53
|
+
task_identifier,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def register_task(
|
|
58
|
+
task_identifier: str,
|
|
59
|
+
callable_: TaskCallable[typing.Any],
|
|
60
|
+
) -> None:
|
|
61
|
+
global registered_tasks
|
|
62
|
+
|
|
63
|
+
registered_task = RegisteredTask(
|
|
64
|
+
task_identifier=task_identifier,
|
|
65
|
+
task_function=callable_,
|
|
66
|
+
)
|
|
67
|
+
registered_tasks[task_identifier] = registered_task
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def register_recurring_task(
|
|
71
|
+
task_identifier: str,
|
|
72
|
+
callable_: TaskCallable[typing.Any],
|
|
73
|
+
**task_kwargs: typing.Any,
|
|
74
|
+
) -> None:
|
|
75
|
+
global registered_tasks
|
|
76
|
+
|
|
77
|
+
logger.debug("Registering recurring task '%s'", task_identifier)
|
|
78
|
+
|
|
79
|
+
registered_task = RegisteredTask(
|
|
80
|
+
task_identifier=task_identifier,
|
|
81
|
+
task_function=callable_,
|
|
82
|
+
task_type=TaskType.RECURRING,
|
|
83
|
+
task_kwargs=task_kwargs,
|
|
84
|
+
)
|
|
85
|
+
registered_tasks[task_identifier] = registered_task
|
|
86
|
+
|
|
87
|
+
logger.debug(
|
|
88
|
+
"Registered tasks now has the following tasks registered: %s",
|
|
89
|
+
list(registered_tasks.keys()),
|
|
90
|
+
)
|