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,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
@@ -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
@@ -0,0 +1,5 @@
1
+ from django.urls import path
2
+
3
+ from task_processor.views import monitoring
4
+
5
+ urlpatterns = [path("monitoring/", monitoring)]
@@ -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()
@@ -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
+ )