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.
Files changed (92) hide show
  1. common/__init__.py +0 -0
  2. common/core/__init__.py +6 -0
  3. common/core/app.py +6 -0
  4. common/core/cli/__init__.py +0 -0
  5. common/core/cli/healthcheck.py +120 -0
  6. common/core/logging.py +24 -0
  7. common/core/main.py +105 -0
  8. common/core/management/__init__.py +0 -0
  9. common/core/management/commands/__init__.py +0 -0
  10. common/core/management/commands/docgen.py +63 -0
  11. common/core/management/commands/start.py +61 -0
  12. common/core/management/commands/waitfordb.py +87 -0
  13. common/core/metrics.py +25 -0
  14. common/core/middleware.py +22 -0
  15. common/core/templates/docgen-metrics.md +22 -0
  16. common/core/urls.py +17 -0
  17. common/core/utils.py +239 -0
  18. common/core/views.py +27 -0
  19. common/environments/permissions.py +15 -0
  20. common/features/__init__.py +0 -0
  21. common/features/multivariate/__init__.py +0 -0
  22. common/features/multivariate/serializers.py +19 -0
  23. common/features/serializers.py +68 -0
  24. common/features/versioning/__init__.py +0 -0
  25. common/features/versioning/serializers.py +13 -0
  26. common/gunicorn/__init__.py +0 -0
  27. common/gunicorn/conf.py +18 -0
  28. common/gunicorn/constants.py +23 -0
  29. common/gunicorn/logging.py +120 -0
  30. common/gunicorn/metrics.py +26 -0
  31. common/gunicorn/middleware.py +30 -0
  32. common/gunicorn/utils.py +104 -0
  33. common/migrations/__init__.py +0 -0
  34. common/migrations/helpers/__init__.py +9 -0
  35. common/migrations/helpers/postgres_helpers.py +41 -0
  36. common/organisations/permissions.py +10 -0
  37. common/projects/permissions.py +40 -0
  38. common/prometheus/__init__.py +3 -0
  39. common/prometheus/utils.py +38 -0
  40. common/py.typed +0 -0
  41. common/test_tools/__init__.py +11 -0
  42. common/test_tools/plugin.py +139 -0
  43. common/test_tools/types.py +56 -0
  44. common/test_tools/utils.py +11 -0
  45. common/types.py +45 -0
  46. flagsmith_common-2.2.4.dist-info/METADATA +196 -0
  47. flagsmith_common-2.2.4.dist-info/RECORD +92 -0
  48. flagsmith_common-2.2.4.dist-info/WHEEL +4 -0
  49. flagsmith_common-2.2.4.dist-info/entry_points.txt +6 -0
  50. flagsmith_common-2.2.4.dist-info/licenses/LICENSE +28 -0
  51. task_processor/__init__.py +0 -0
  52. task_processor/admin.py +38 -0
  53. task_processor/apps.py +47 -0
  54. task_processor/decorators.py +209 -0
  55. task_processor/exceptions.py +28 -0
  56. task_processor/health.py +44 -0
  57. task_processor/managers.py +18 -0
  58. task_processor/metrics.py +22 -0
  59. task_processor/migrations/0001_initial.py +44 -0
  60. task_processor/migrations/0002_healthcheckmodel.py +21 -0
  61. task_processor/migrations/0003_add_completed_to_task.py +22 -0
  62. task_processor/migrations/0004_recreate_task_indexes.py +43 -0
  63. task_processor/migrations/0005_update_conditional_index_conditions.py +45 -0
  64. task_processor/migrations/0006_auto_20230221_0802.py +45 -0
  65. task_processor/migrations/0007_add_is_locked.py +23 -0
  66. task_processor/migrations/0008_add_get_task_to_process_function.py +31 -0
  67. task_processor/migrations/0009_add_recurring_task_run_first_run_at.py +18 -0
  68. task_processor/migrations/0010_task_priority.py +27 -0
  69. task_processor/migrations/0011_add_priority_to_get_tasks_to_process.py +27 -0
  70. task_processor/migrations/0012_add_locked_at_and_timeout.py +40 -0
  71. task_processor/migrations/0013_add_last_picked_at.py +34 -0
  72. task_processor/migrations/__init__.py +0 -0
  73. task_processor/migrations/sql/0008_get_recurring_tasks_to_process.sql +30 -0
  74. task_processor/migrations/sql/0008_get_tasks_to_process.sql +30 -0
  75. task_processor/migrations/sql/0011_get_tasks_to_process.sql +30 -0
  76. task_processor/migrations/sql/0012_get_recurringtasks_to_process.sql +33 -0
  77. task_processor/migrations/sql/0013_get_recurringtasks_to_process.sql +33 -0
  78. task_processor/migrations/sql/__init__.py +0 -0
  79. task_processor/models.py +237 -0
  80. task_processor/monitoring.py +12 -0
  81. task_processor/processor.py +202 -0
  82. task_processor/py.typed +0 -0
  83. task_processor/routers.py +55 -0
  84. task_processor/serializers.py +7 -0
  85. task_processor/task_registry.py +90 -0
  86. task_processor/task_run_method.py +7 -0
  87. task_processor/tasks.py +71 -0
  88. task_processor/threads.py +128 -0
  89. task_processor/types.py +18 -0
  90. task_processor/urls.py +5 -0
  91. task_processor/utils.py +71 -0
  92. 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
@@ -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
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,7 @@
1
+ from rest_framework import serializers
2
+
3
+ from task_processor.types import MonitoringInfo
4
+
5
+
6
+ class MonitoringSerializer(serializers.Serializer[MonitoringInfo]):
7
+ waiting = serializers.IntegerField(read_only=True)
@@ -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
+ )
@@ -0,0 +1,7 @@
1
+ from enum import Enum
2
+
3
+
4
+ class TaskRunMethod(Enum):
5
+ SYNCHRONOUSLY = "SYNCHRONOUSLY"
6
+ SEPARATE_THREAD = "SEPARATE_THREAD"
7
+ TASK_PROCESSOR = "TASK_PROCESSOR"