flagsmith-common 3.8.1__tar.gz → 3.9.0__tar.gz

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 (114) hide show
  1. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/PKG-INFO +2 -2
  2. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/pyproject.toml +2 -2
  3. flagsmith_common-3.9.0/src/common/core/templates/docgen-events.md +9 -0
  4. flagsmith_common-3.9.0/src/common/core/templates/docgen-metrics.md +10 -0
  5. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/admin.py +12 -0
  6. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/exceptions.py +10 -0
  7. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/managers.py +5 -2
  8. flagsmith_common-3.9.0/src/task_processor/migrations/0015_add_is_disabled.py +37 -0
  9. flagsmith_common-3.9.0/src/task_processor/migrations/sql/0015_get_recurringtasks_to_process.sql +33 -0
  10. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/models.py +44 -1
  11. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/processor.py +53 -37
  12. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/threads.py +2 -2
  13. flagsmith_common-3.8.1/src/common/core/templates/docgen-events.md +0 -20
  14. flagsmith_common-3.8.1/src/common/core/templates/docgen-metrics.md +0 -24
  15. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/LICENSE +0 -0
  16. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/README.md +0 -0
  17. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/__init__.py +0 -0
  18. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/__init__.py +0 -0
  19. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/app.py +0 -0
  20. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/cli/__init__.py +0 -0
  21. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/cli/healthcheck.py +0 -0
  22. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/constants.py +0 -0
  23. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/docgen/__init__.py +0 -0
  24. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/docgen/events.py +0 -0
  25. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/logging.py +0 -0
  26. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/main.py +0 -0
  27. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/management/__init__.py +0 -0
  28. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/management/commands/__init__.py +0 -0
  29. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/management/commands/docgen.py +0 -0
  30. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/management/commands/start.py +0 -0
  31. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/management/commands/waitfordb.py +0 -0
  32. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/metrics.py +0 -0
  33. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/middleware.py +0 -0
  34. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/otel.py +0 -0
  35. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/sentry.py +0 -0
  36. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/urls.py +0 -0
  37. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/utils.py +0 -0
  38. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/core/views.py +0 -0
  39. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/environments/permissions.py +0 -0
  40. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/features/__init__.py +0 -0
  41. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/features/multivariate/__init__.py +0 -0
  42. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/features/multivariate/serializers.py +0 -0
  43. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/features/serializers.py +0 -0
  44. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/features/versioning/__init__.py +0 -0
  45. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/features/versioning/serializers.py +0 -0
  46. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/gunicorn/__init__.py +0 -0
  47. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/gunicorn/conf.py +0 -0
  48. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/gunicorn/constants.py +0 -0
  49. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/gunicorn/logging.py +0 -0
  50. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/gunicorn/metrics.py +0 -0
  51. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/gunicorn/metrics_server.py +0 -0
  52. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/gunicorn/middleware.py +0 -0
  53. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/gunicorn/processors.py +0 -0
  54. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/gunicorn/utils.py +0 -0
  55. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/lint_tests.py +0 -0
  56. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/migrations/__init__.py +0 -0
  57. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/migrations/helpers/__init__.py +0 -0
  58. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/migrations/helpers/postgres_helpers.py +0 -0
  59. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/organisations/permissions.py +0 -0
  60. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/projects/permissions.py +0 -0
  61. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/prometheus/__init__.py +0 -0
  62. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/prometheus/utils.py +0 -0
  63. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/py.typed +0 -0
  64. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/test_tools/__init__.py +0 -0
  65. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/test_tools/plugin.py +0 -0
  66. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/test_tools/types.py +0 -0
  67. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/test_tools/utils.py +0 -0
  68. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/common/types.py +0 -0
  69. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/flagsmith_schemas/__init__.py +0 -0
  70. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/flagsmith_schemas/api.py +0 -0
  71. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/flagsmith_schemas/constants.py +0 -0
  72. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/flagsmith_schemas/dynamodb.py +0 -0
  73. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/flagsmith_schemas/py.typed +0 -0
  74. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/flagsmith_schemas/pydantic_types.py +0 -0
  75. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/flagsmith_schemas/types.py +0 -0
  76. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/flagsmith_schemas/utils.py +0 -0
  77. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/flagsmith_schemas/validators.py +0 -0
  78. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/__init__.py +0 -0
  79. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/apps.py +0 -0
  80. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/decorators.py +0 -0
  81. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/health.py +0 -0
  82. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/metrics.py +0 -0
  83. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/0001_initial.py +0 -0
  84. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/0002_healthcheckmodel.py +0 -0
  85. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/0003_add_completed_to_task.py +0 -0
  86. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/0004_recreate_task_indexes.py +0 -0
  87. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/0005_update_conditional_index_conditions.py +0 -0
  88. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/0006_auto_20230221_0802.py +0 -0
  89. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/0007_add_is_locked.py +0 -0
  90. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/0008_add_get_task_to_process_function.py +0 -0
  91. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/0009_add_recurring_task_run_first_run_at.py +0 -0
  92. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/0010_task_priority.py +0 -0
  93. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/0011_add_priority_to_get_tasks_to_process.py +0 -0
  94. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/0012_add_locked_at_and_timeout.py +0 -0
  95. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/0013_add_last_picked_at.py +0 -0
  96. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/0014_add_trace_context.py +0 -0
  97. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/__init__.py +0 -0
  98. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/sql/0008_get_recurring_tasks_to_process.sql +0 -0
  99. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/sql/0008_get_tasks_to_process.sql +0 -0
  100. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/sql/0011_get_tasks_to_process.sql +0 -0
  101. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/sql/0012_get_recurringtasks_to_process.sql +0 -0
  102. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/sql/0013_get_recurringtasks_to_process.sql +0 -0
  103. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/migrations/sql/__init__.py +0 -0
  104. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/monitoring.py +0 -0
  105. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/py.typed +0 -0
  106. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/routers.py +0 -0
  107. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/serializers.py +0 -0
  108. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/task_registry.py +0 -0
  109. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/task_run_method.py +0 -0
  110. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/tasks.py +0 -0
  111. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/types.py +0 -0
  112. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/urls.py +0 -0
  113. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/utils.py +0 -0
  114. {flagsmith_common-3.8.1 → flagsmith_common-3.9.0}/src/task_processor/views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flagsmith-common
3
- Version: 3.8.1
3
+ Version: 3.9.0
4
4
  Summary: Flagsmith's common library
5
5
  Author: Matthew Elwell, Gagan Trivedi, Kim Gustyr, Zach Aysan, Francesco Lo Franco, Rodrigo López Dato, Evandro Myller, Wadii Zaim
6
6
  License-Expression: BSD-3-Clause
@@ -15,7 +15,7 @@ Requires-Dist: djangorestframework-recursive ; extra == 'common-core'
15
15
  Requires-Dist: djangorestframework ; extra == 'common-core'
16
16
  Requires-Dist: drf-spectacular>=0.28.0,<1 ; extra == 'common-core'
17
17
  Requires-Dist: drf-writable-nested ; extra == 'common-core'
18
- Requires-Dist: environs<15 ; extra == 'common-core'
18
+ Requires-Dist: environs<16 ; extra == 'common-core'
19
19
  Requires-Dist: gunicorn>=19.1 ; extra == 'common-core'
20
20
  Requires-Dist: inflection ; extra == 'common-core'
21
21
  Requires-Dist: opentelemetry-api>=1.25,<2 ; extra == 'common-core'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flagsmith-common"
3
- version = "3.8.1"
3
+ version = "3.9.0"
4
4
  description = "Flagsmith's common library"
5
5
  requires-python = ">=3.11,<4.0"
6
6
  dependencies = []
@@ -14,7 +14,7 @@ optional-dependencies = { test-tools = [
14
14
  "djangorestframework",
15
15
  "drf-spectacular (>=0.28.0,<1)",
16
16
  "drf-writable-nested",
17
- "environs (<15)",
17
+ "environs (<16)",
18
18
  "gunicorn (>=19.1)",
19
19
  "inflection",
20
20
  "opentelemetry-api (>=1.25,<2)",
@@ -0,0 +1,9 @@
1
+ {% for event in flagsmith_events %}
2
+ ### `{{ event.name }}`
3
+
4
+ Logged at `{{ event.level }}` from:
5
+ {% for location in event.locations %} - `{{ location.path }}:{{ location.line }}`
6
+ {% endfor %}
7
+ Attributes:
8
+ {% for attr in event.attributes %} - `{{ attr }}`
9
+ {% endfor %}{% endfor %}
@@ -0,0 +1,10 @@
1
+ {% for metric in flagsmith_metrics %}
2
+ ### `{{ metric.name }}`
3
+
4
+ {{ metric.type|title }}.
5
+
6
+ {{ metric.documentation }}
7
+
8
+ Labels:
9
+ {% for label in metric.labels %} - `{{ label }}`
10
+ {% endfor %}{% endfor %}
@@ -16,7 +16,11 @@ class RecurringTaskAdmin(admin.ModelAdmin[RecurringTask]):
16
16
  "last_run_status",
17
17
  "last_run_finished_at",
18
18
  "is_locked",
19
+ "is_disabled",
20
+ "num_consecutive_failures",
19
21
  )
22
+ list_filter = ("is_disabled",)
23
+ actions = ("unlock", "enable")
20
24
  readonly_fields = ("args", "kwargs")
21
25
 
22
26
  def last_run_status(self, instance: RecurringTask) -> str | None:
@@ -36,3 +40,11 @@ class RecurringTaskAdmin(admin.ModelAdmin[RecurringTask]):
36
40
  queryset: QuerySet[RecurringTask],
37
41
  ) -> None:
38
42
  queryset.update(is_locked=False)
43
+
44
+ @admin.action(description="Re-enable selected tasks")
45
+ def enable(
46
+ self,
47
+ request: HttpRequest,
48
+ queryset: QuerySet[RecurringTask],
49
+ ) -> None:
50
+ queryset.update(is_disabled=False, num_consecutive_failures=0)
@@ -26,3 +26,13 @@ class TaskBackoffError(TaskProcessingError):
26
26
 
27
27
  class TaskQueueFullError(Exception):
28
28
  pass
29
+
30
+
31
+ class TaskAbandonedError(TaskProcessingError):
32
+ """
33
+ Marker error for recurring task runs whose worker died before
34
+ recording the result (process killed, OOM, host evicted, DB
35
+ connection lost during the post-execution save). Never raised —
36
+ used as the prefix in `error_details` so monitoring and log scrapers
37
+ can match on a single authoritative class name.
38
+ """
@@ -14,5 +14,8 @@ class TaskManager(Manager["Task"]):
14
14
 
15
15
 
16
16
  class RecurringTaskManager(Manager["RecurringTask"]):
17
- def get_tasks_to_process(self) -> "RawQuerySet[RecurringTask]":
18
- return self.raw("SELECT * FROM get_recurringtasks_to_process()")
17
+ def get_task_to_process(self) -> "RecurringTask | None":
18
+ return next(
19
+ iter(self.raw("SELECT * FROM get_recurringtasks_to_process()")),
20
+ None,
21
+ )
@@ -0,0 +1,37 @@
1
+ import os
2
+
3
+ from django.db import migrations, models
4
+
5
+ from common.migrations.helpers import PostgresOnlyRunSQL
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ dependencies = [
11
+ ("task_processor", "0014_add_trace_context"),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.AddField(
16
+ model_name="recurringtask",
17
+ name="is_disabled",
18
+ field=models.BooleanField(default=False),
19
+ ),
20
+ migrations.AddField(
21
+ model_name="recurringtask",
22
+ name="num_consecutive_failures",
23
+ field=models.IntegerField(default=0),
24
+ ),
25
+ PostgresOnlyRunSQL.from_sql_file(
26
+ os.path.join(
27
+ os.path.dirname(__file__),
28
+ "sql",
29
+ "0015_get_recurringtasks_to_process.sql",
30
+ ),
31
+ reverse_sql=os.path.join(
32
+ os.path.dirname(__file__),
33
+ "sql",
34
+ "0013_get_recurringtasks_to_process.sql",
35
+ ),
36
+ ),
37
+ ]
@@ -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
+ -- Skip disabled tasks; add one minute to the timeout as a grace period for overhead
11
+ WHERE is_disabled = FALSE
12
+ AND (is_locked = FALSE OR (locked_at IS NOT NULL AND locked_at < NOW() - timeout + INTERVAL '1 minute'))
13
+ ORDER BY last_picked_at NULLS FIRST
14
+ LIMIT 1
15
+ -- Select for update to ensure that no other workers can select these tasks while in this transaction block
16
+ FOR UPDATE SKIP LOCKED
17
+ LOOP
18
+ -- Lock every selected task(by updating `is_locked` to true)
19
+ UPDATE task_processor_recurringtask
20
+ -- Lock this row by setting is_locked True, so that no other workers can select these tasks after this
21
+ -- transaction is complete (but the tasks are still being executed by the current worker)
22
+ SET is_locked = TRUE, locked_at = NOW(), last_picked_at = NOW()
23
+ WHERE id = row_to_return.id;
24
+ -- If we don't explicitly update the columns here, the client will receive a row
25
+ -- that is locked but still shows `is_locked` as `False` and `locked_at` as `None`.
26
+ row_to_return.is_locked := TRUE;
27
+ row_to_return.locked_at := NOW();
28
+ RETURN NEXT row_to_return;
29
+ END LOOP;
30
+
31
+ RETURN;
32
+ END;
33
+ $$ LANGUAGE plpgsql
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import typing
2
3
  import uuid
3
4
  from datetime import datetime, timedelta
@@ -7,11 +8,13 @@ from django.core.serializers.json import DjangoJSONEncoder
7
8
  from django.db import models
8
9
  from django.utils import timezone
9
10
 
10
- from task_processor.exceptions import TaskQueueFullError
11
+ from task_processor.exceptions import TaskAbandonedError, TaskQueueFullError
11
12
  from task_processor.managers import RecurringTaskManager, TaskManager
12
13
  from task_processor.task_registry import get_task, registered_tasks
13
14
  from task_processor.types import TaskCallable, TraceContext
14
15
 
16
+ logger = logging.getLogger(__name__)
17
+
15
18
  _django_json_encoder_default = DjangoJSONEncoder().default
16
19
 
17
20
 
@@ -151,6 +154,8 @@ class Task(AbstractBaseTask):
151
154
 
152
155
 
153
156
  class RecurringTask(AbstractBaseTask):
157
+ MAX_CONSECUTIVE_FAILURES = 4
158
+
154
159
  run_every = models.DurationField()
155
160
  first_run_time = models.TimeField(blank=True, null=True)
156
161
 
@@ -158,6 +163,8 @@ class RecurringTask(AbstractBaseTask):
158
163
  timeout = models.DurationField(default=timedelta(minutes=30))
159
164
 
160
165
  last_picked_at = models.DateTimeField(blank=True, null=True)
166
+ is_disabled = models.BooleanField(default=False)
167
+ num_consecutive_failures = models.IntegerField(default=0)
161
168
  objects: RecurringTaskManager = RecurringTaskManager()
162
169
 
163
170
  class Meta:
@@ -172,6 +179,42 @@ class RecurringTask(AbstractBaseTask):
172
179
  self.is_locked = False
173
180
  self.locked_at = None
174
181
 
182
+ def reconcile_abandoned_run(self) -> None:
183
+ # if for some reason the worker died before before writing the task run result
184
+ # we mark that run as explict failure here
185
+ abandoned_run = self.task_runs.filter(result__isnull=True).first()
186
+ if abandoned_run is None:
187
+ return
188
+ abandoned_run.finished_at = timezone.now()
189
+ abandoned_run.result = TaskResult.FAILURE.value
190
+ abandoned_run.error_details = (
191
+ f"{TaskAbandonedError.__name__}: "
192
+ "no result was written before the SQL reaper unlocked the task"
193
+ )
194
+ abandoned_run.save(
195
+ update_fields=["finished_at", "result", "error_details"],
196
+ )
197
+ logger.error(
198
+ "Recurring task '%s' was abandoned: %s",
199
+ self.task_identifier,
200
+ abandoned_run.error_details,
201
+ )
202
+
203
+ def mark_failure(self) -> None:
204
+ super().mark_failure()
205
+ self.num_consecutive_failures += 1
206
+ if self.num_consecutive_failures >= self.MAX_CONSECUTIVE_FAILURES:
207
+ self.is_disabled = True
208
+ logger.error(
209
+ "Recurring task '%s' auto-disabled after %d consecutive failures",
210
+ self.task_identifier,
211
+ self.num_consecutive_failures,
212
+ )
213
+
214
+ def mark_success(self) -> None:
215
+ super().mark_success()
216
+ self.num_consecutive_failures = 0
217
+
175
218
  @property
176
219
  def should_execute(self) -> bool:
177
220
  now = timezone.now()
@@ -7,6 +7,7 @@ from datetime import timedelta
7
7
  from importlib.metadata import version
8
8
 
9
9
  from django.conf import settings
10
+ from django.db import close_old_connections
10
11
  from django.utils import timezone
11
12
  from opentelemetry import context as otel_context
12
13
  from opentelemetry import propagate, trace
@@ -68,52 +69,66 @@ def run_tasks(database: str, num_tasks: int = 1) -> list[TaskRun]:
68
69
  return []
69
70
 
70
71
 
71
- def run_recurring_tasks(database: str) -> list[RecurringTaskRun]:
72
+ def run_recurring_task(database: str) -> RecurringTaskRun | None:
72
73
  # NOTE: We will probably see a lot of delay in the execution of recurring tasks
73
74
  # if the tasks take longer then `run_every` to execute. This is not
74
75
  # a problem for now, but we should be mindful of this limitation
75
76
  task_manager: RecurringTaskManager = RecurringTask.objects.db_manager(database)
76
- tasks = task_manager.get_tasks_to_process()
77
- if tasks:
78
- logger.debug(f"Running {len(tasks)} recurring task(s)")
79
-
80
- task_runs = []
81
-
82
- for task in tasks:
83
- if not task.is_task_registered:
84
- # This is necessary to ensure that old instances of the task processor,
85
- # which may still be running during deployment, do not remove tasks added by new instances.
86
- # Reference: https://github.com/Flagsmith/flagsmith/issues/2551
87
- task_age = timezone.now() - task.created_at
88
- if task_age > UNREGISTERED_RECURRING_TASK_GRACE_PERIOD:
89
- task.delete(using=database)
90
- continue
91
-
92
- if task.should_execute:
93
- task, task_run = _run_task(task)
94
- assert isinstance(task_run, RecurringTaskRun)
95
- task_runs.append(task_run)
96
- else:
97
- task.unlock()
98
-
99
- # update all tasks that were not deleted
100
- to_update = [task for task in tasks if task.id]
101
- RecurringTask.objects.using(database).bulk_update(
102
- to_update,
103
- fields=["is_locked", "locked_at"],
104
- )
77
+ task = task_manager.get_task_to_process()
78
+ if task is None:
79
+ return None
80
+
81
+ logger.debug(f"Running recurring task '{task.task_identifier}'")
82
+
83
+ task.reconcile_abandoned_run()
84
+
85
+ if not task.is_task_registered:
86
+ # This is necessary to ensure that old instances of the task processor,
87
+ # which may still be running during deployment, do not remove tasks added by new instances.
88
+ # Reference: https://github.com/Flagsmith/flagsmith/issues/2551
89
+ task_age = timezone.now() - task.created_at
90
+ if task_age > UNREGISTERED_RECURRING_TASK_GRACE_PERIOD:
91
+ task.delete(using=database)
92
+ return None
93
+
94
+ task_run: RecurringTaskRun | None = None
95
+ if task.should_execute:
96
+ # Persist the task run before execution so that, if the worker is
97
+ # killed mid-task, we still have a row we can later mark as timed
98
+ # out when the task is unlocked by the timeout-based reaper in
99
+ # `get_recurringtasks_to_process`.
100
+ task_run = RecurringTaskRun(started_at=timezone.now(), task=task)
101
+ task_run.save(using=database)
102
+ task, run = _run_task(task, task_run=task_run)
103
+ assert run is task_run
104
+ # task.run() may have idled the DB connection past the server's
105
+ # session timeout; drop stale connections so the saves below open
106
+ # a fresh one. See Sentry FLAGSMITH-API-5EM.
107
+ close_old_connections()
108
+ else:
109
+ task.unlock()
110
+
111
+ task.save(
112
+ using=database,
113
+ update_fields=[
114
+ "is_locked",
115
+ "locked_at",
116
+ "is_disabled",
117
+ "num_consecutive_failures",
118
+ ],
119
+ )
105
120
 
106
- if task_runs:
107
- RecurringTaskRun.objects.using(database).bulk_create(task_runs)
108
- logger.debug(f"Finished running {len(task_runs)} recurring task(s)")
121
+ if task_run:
122
+ task_run.save(using=database)
123
+ logger.debug(f"Finished running recurring task '{task.task_identifier}'")
124
+ return task_run
109
125
 
110
- return task_runs
111
-
112
- return []
126
+ return None
113
127
 
114
128
 
115
129
  def _run_task(
116
130
  task: T,
131
+ task_run: AnyTaskRun | None = None,
117
132
  ) -> typing.Tuple[T, AnyTaskRun]:
118
133
  assert settings.TASK_PROCESSOR_MODE, (
119
134
  "Attempt to run tasks in a non-task-processor environment"
@@ -129,7 +144,8 @@ def _run_task(
129
144
  logger.debug(
130
145
  f"Running task {task_identifier} id={task.pk} args={task.args} kwargs={task.kwargs}"
131
146
  )
132
- task_run: AnyTaskRun = task.task_runs.model(started_at=timezone.now(), task=task) # type: ignore[attr-defined]
147
+ if task_run is None:
148
+ task_run = task.task_runs.model(started_at=timezone.now(), task=task) # type: ignore[attr-defined]
133
149
  result: str
134
150
  executor = None
135
151
 
@@ -8,7 +8,7 @@ from django.conf import settings
8
8
  from django.db import close_old_connections
9
9
  from django.utils import timezone
10
10
 
11
- from task_processor.processor import run_recurring_tasks, run_tasks
11
+ from task_processor.processor import run_recurring_task, run_tasks
12
12
  from task_processor.task_registry import initialise
13
13
  from task_processor.types import TaskProcessorConfig
14
14
 
@@ -111,7 +111,7 @@ class TaskRunner(Thread):
111
111
 
112
112
  # Recurring tasks are only run on one database
113
113
  if (database == "default") ^ database_is_separate:
114
- run_recurring_tasks(database)
114
+ run_recurring_task(database)
115
115
  except Exception as exception:
116
116
  # To prevent task threads from dying if they get an error retrieving the tasks from the
117
117
  # database this will allow the thread to continue trying to retrieve tasks if it can
@@ -1,20 +0,0 @@
1
- ---
2
- title: Events
3
- sidebar_label: Events
4
- sidebar_position: 30
5
- ---
6
-
7
- Flagsmith backend emits [OpenTelemetry events](https://opentelemetry.io/docs/specs/otel/logs/data-model/#events)
8
- that can be ingested to downstream observability systems and/or a data warehouse of your choice via OTLP.
9
- To learn how to configure this, see [OpenTelemetry](deployment-self-hosting/scaling-and-performance/opentelemetry).
10
-
11
- ## Event catalogue
12
- {% for event in flagsmith_events %}
13
- ### `{{ event.name }}`
14
-
15
- Logged at `{{ event.level }}` from:
16
- {% for location in event.locations %} - `{{ location.path }}:{{ location.line }}`
17
- {% endfor %}
18
- Attributes:
19
- {% for attr in event.attributes %} - `{{ attr }}`
20
- {% endfor %}{% endfor %}
@@ -1,24 +0,0 @@
1
- ---
2
- title: Metrics
3
- sidebar_label: Metrics
4
- sidebar_position: 20
5
- ---
6
-
7
- ## Prometheus
8
-
9
- To enable the Prometheus `/metrics` endpoint, set the `PROMETHEUS_ENABLED` environment variable to `true`.
10
-
11
- When enabled, Flagsmith serves the `/metrics` endpoint on port 9100.
12
-
13
- The metrics provided by Flagsmith are described below.
14
-
15
- {% for metric in flagsmith_metrics %}
16
- ### `{{ metric.name }}`
17
-
18
- {{ metric.type|title }}.
19
-
20
- {{ metric.documentation }}
21
-
22
- Labels:
23
- {% for label in metric.labels %} - `{{ label }}`
24
- {% endfor %}{% endfor %}