Skip to content

Commit 2da8be0

Browse files
committed
fix: resolve package-lock.json merge conflict
2 parents 17507af + 7d3b541 commit 2da8be0

18 files changed

Lines changed: 388 additions & 21 deletions

api/app/settings/common.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,9 @@
784784
"REDIS_CLUSTER_READ_FROM_REPLICAS", default=True
785785
)
786786

787+
# Redis Cluster URL used to communicate with the event ingestion server.
788+
INGESTION_REDIS_URL = env.str("INGESTION_REDIS_URL", default="")
789+
787790
CACHES = {
788791
"default": {
789792
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
from functools import lru_cache
4+
5+
from django.conf import settings
6+
from redis.cluster import RedisCluster
7+
8+
INGESTION_ENVIRONMENT_KEY_PREFIX = "experimentation:environment_keys:"
9+
10+
11+
SOCKET_TIMEOUT = 1
12+
13+
14+
@lru_cache(maxsize=1)
15+
def _get_client() -> RedisCluster:
16+
return RedisCluster.from_url( # type: ignore[no-untyped-call,no-any-return]
17+
settings.INGESTION_REDIS_URL,
18+
socket_timeout=SOCKET_TIMEOUT,
19+
socket_keepalive=True,
20+
)
21+
22+
23+
def set_environment_key(environment_api_key: str) -> None:
24+
key = f"{INGESTION_ENVIRONMENT_KEY_PREFIX}{environment_api_key}"
25+
_get_client().set(key, "")
26+
27+
28+
def delete_environment_key(environment_api_key: str) -> None:
29+
key = f"{INGESTION_ENVIRONMENT_KEY_PREFIX}{environment_api_key}"
30+
_get_client().delete(key)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
6+
dependencies = [
7+
("experimentation", "0002_add_config_and_created_status"),
8+
]
9+
10+
operations = [
11+
migrations.RemoveConstraint(
12+
model_name="warehouseconnection",
13+
name="unique_active_warehouse_per_type_and_env",
14+
),
15+
migrations.AddConstraint(
16+
model_name="warehouseconnection",
17+
constraint=models.UniqueConstraint(
18+
condition=models.Q(("deleted_at__isnull", True)),
19+
fields=("environment",),
20+
name="unique_active_warehouse_per_env",
21+
),
22+
),
23+
]

api/experimentation/models.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
from django.db import models
2-
from django_lifecycle import LifecycleModelMixin # type: ignore[import-untyped]
2+
from django_lifecycle import ( # type: ignore[import-untyped]
3+
AFTER_CREATE,
4+
AFTER_DELETE,
5+
LifecycleModelMixin,
6+
hook,
7+
)
38

49
from core.models import SoftDeleteExportableModel
510
from environments.models import Environment
11+
from experimentation.tasks import (
12+
add_environment_key_to_ingestion,
13+
delete_environment_key_from_ingestion,
14+
)
615

716

817
class WarehouseType(models.TextChoices):
@@ -42,8 +51,20 @@ class WarehouseConnection(LifecycleModelMixin, SoftDeleteExportableModel): # ty
4251
class Meta:
4352
constraints = [
4453
models.UniqueConstraint(
45-
fields=["warehouse_type", "environment"],
54+
fields=["environment"],
4655
condition=models.Q(deleted_at__isnull=True),
47-
name="unique_active_warehouse_per_type_and_env",
56+
name="unique_active_warehouse_per_env",
4857
),
4958
]
59+
60+
@hook(AFTER_CREATE) # type: ignore[misc]
61+
def sync_to_ingestion_on_create(self) -> None:
62+
add_environment_key_to_ingestion.delay(
63+
kwargs={"environment_api_key": self.environment.api_key},
64+
)
65+
66+
@hook(AFTER_DELETE) # type: ignore[misc]
67+
def sync_to_ingestion_on_delete(self) -> None:
68+
delete_environment_key_from_ingestion.delay(
69+
kwargs={"environment_api_key": self.environment.api_key},
70+
)

api/experimentation/tasks.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from task_processor.decorators import register_task_handler
2+
3+
from experimentation import ingestion_sync_service
4+
5+
6+
@register_task_handler()
7+
def add_environment_key_to_ingestion(environment_api_key: str) -> None:
8+
ingestion_sync_service.set_environment_key(environment_api_key)
9+
10+
11+
@register_task_handler()
12+
def delete_environment_key_from_ingestion(environment_api_key: str) -> None:
13+
ingestion_sync_service.delete_environment_key(environment_api_key)

api/experimentation/views.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,13 @@ def create(self, request: Request, *args: object, **kwargs: object) -> Response:
5252
serializer = self.get_serializer(data=request.data)
5353
serializer.is_valid(raise_exception=True)
5454

55-
warehouse_type = serializer.validated_data["warehouse_type"]
5655
if WarehouseConnection.objects.filter(
5756
environment=environment,
58-
warehouse_type=warehouse_type,
5957
).exists():
6058
return Response(
61-
{"detail": "Warehouse connection already exists."},
59+
{
60+
"detail": "This environment already has an active warehouse connection."
61+
},
6262
status=409,
6363
)
6464

api/tests/unit/experimentation/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import pytest
22
from django.urls import reverse
3+
from pytest_mock import MockerFixture
34

45
from environments.models import Environment
6+
from experimentation import ingestion_sync_service
57
from experimentation.models import WarehouseConnection, WarehouseType
68

79

10+
@pytest.fixture(autouse=True)
11+
def mock_ingestion_redis_client(mocker: MockerFixture) -> None:
12+
ingestion_sync_service._get_client.cache_clear()
13+
mocker.patch("experimentation.ingestion_sync_service.RedisCluster.from_url")
14+
15+
816
@pytest.fixture()
917
def warehouse_connection(environment: Environment) -> WarehouseConnection:
1018
connection: WarehouseConnection = WarehouseConnection.objects.create(
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import pytest
2+
from pytest_mock import MockerFixture
3+
from redis.exceptions import RedisError
4+
5+
from experimentation import ingestion_sync_service
6+
7+
8+
def test_get_client__configured_url__builds_redis_cluster_with_socket_options(
9+
mocker: MockerFixture,
10+
settings: object,
11+
) -> None:
12+
# Given
13+
settings.INGESTION_REDIS_URL = "redis://ingestion:6379" # type: ignore[attr-defined]
14+
mock_from_url = mocker.patch(
15+
"experimentation.ingestion_sync_service.RedisCluster.from_url",
16+
)
17+
18+
# When
19+
client = ingestion_sync_service._get_client()
20+
21+
# Then
22+
mock_from_url.assert_called_once_with(
23+
"redis://ingestion:6379",
24+
socket_timeout=ingestion_sync_service.SOCKET_TIMEOUT,
25+
socket_keepalive=True,
26+
)
27+
assert client is mock_from_url.return_value
28+
29+
30+
def test_set_environment_key__valid_key__writes_to_redis(
31+
mocker: MockerFixture,
32+
) -> None:
33+
# Given
34+
mock_client = mocker.Mock()
35+
mocker.patch(
36+
"experimentation.ingestion_sync_service._get_client",
37+
return_value=mock_client,
38+
)
39+
40+
# When
41+
ingestion_sync_service.set_environment_key("test-env-key-001")
42+
43+
# Then
44+
mock_client.set.assert_called_once_with(
45+
"experimentation:environment_keys:test-env-key-001",
46+
"",
47+
)
48+
49+
50+
def test_delete_environment_key__valid_key__deletes_from_redis(
51+
mocker: MockerFixture,
52+
) -> None:
53+
# Given
54+
mock_client = mocker.Mock()
55+
mocker.patch(
56+
"experimentation.ingestion_sync_service._get_client",
57+
return_value=mock_client,
58+
)
59+
60+
# When
61+
ingestion_sync_service.delete_environment_key("test-env-key-001")
62+
63+
# Then
64+
mock_client.delete.assert_called_once_with(
65+
"experimentation:environment_keys:test-env-key-001",
66+
)
67+
68+
69+
def test_set_environment_key__redis_error__propagates(
70+
mocker: MockerFixture,
71+
) -> None:
72+
# Given
73+
mock_client = mocker.Mock()
74+
mock_client.set.side_effect = RedisError("boom")
75+
mocker.patch(
76+
"experimentation.ingestion_sync_service._get_client",
77+
return_value=mock_client,
78+
)
79+
80+
# When / Then
81+
with pytest.raises(RedisError, match="boom"):
82+
ingestion_sync_service.set_environment_key("test-env-key-001")
83+
84+
85+
def test_delete_environment_key__redis_error__propagates(
86+
mocker: MockerFixture,
87+
) -> None:
88+
# Given
89+
mock_client = mocker.Mock()
90+
mock_client.delete.side_effect = RedisError("boom")
91+
mocker.patch(
92+
"experimentation.ingestion_sync_service._get_client",
93+
return_value=mock_client,
94+
)
95+
96+
# When / Then
97+
with pytest.raises(RedisError, match="boom"):
98+
ingestion_sync_service.delete_environment_key("test-env-key-001")
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from pytest_mock import MockerFixture
2+
3+
from environments.models import Environment
4+
from experimentation.models import WarehouseConnection, WarehouseType
5+
6+
7+
def test_warehouse_connection__after_create__enqueues_ingestion_add_task(
8+
environment: Environment,
9+
mocker: MockerFixture,
10+
) -> None:
11+
# Given
12+
mock_task = mocker.patch(
13+
"experimentation.models.add_environment_key_to_ingestion",
14+
)
15+
16+
# When
17+
WarehouseConnection.objects.create(
18+
environment=environment,
19+
warehouse_type=WarehouseType.FLAGSMITH,
20+
name="warehouse",
21+
)
22+
23+
# Then
24+
mock_task.delay.assert_called_once_with(
25+
kwargs={"environment_api_key": environment.api_key},
26+
)
27+
28+
29+
def test_warehouse_connection__after_delete__enqueues_ingestion_delete_task(
30+
warehouse_connection: WarehouseConnection,
31+
mocker: MockerFixture,
32+
) -> None:
33+
# Given
34+
mock_task = mocker.patch(
35+
"experimentation.models.delete_environment_key_from_ingestion",
36+
)
37+
environment_api_key = warehouse_connection.environment.api_key
38+
39+
# When
40+
warehouse_connection.delete()
41+
42+
# Then
43+
mock_task.delay.assert_called_once_with(
44+
kwargs={"environment_api_key": environment_api_key},
45+
)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from pytest_mock import MockerFixture
2+
3+
from experimentation.tasks import (
4+
add_environment_key_to_ingestion,
5+
delete_environment_key_from_ingestion,
6+
)
7+
8+
9+
def test_add_environment_key_to_ingestion__valid_key__calls_service(
10+
mocker: MockerFixture,
11+
) -> None:
12+
# Given
13+
mock_set = mocker.patch(
14+
"experimentation.tasks.ingestion_sync_service.set_environment_key",
15+
)
16+
17+
# When
18+
add_environment_key_to_ingestion(environment_api_key="test-env-key-001")
19+
20+
# Then
21+
mock_set.assert_called_once_with("test-env-key-001")
22+
23+
24+
def test_delete_environment_key_from_ingestion__valid_key__calls_service(
25+
mocker: MockerFixture,
26+
) -> None:
27+
# Given
28+
mock_delete = mocker.patch(
29+
"experimentation.tasks.ingestion_sync_service.delete_environment_key",
30+
)
31+
32+
# When
33+
delete_environment_key_from_ingestion(environment_api_key="test-env-key-001")
34+
35+
# Then
36+
mock_delete.assert_called_once_with("test-env-key-001")

0 commit comments

Comments
 (0)