Skip to content

Commit 7d3b541

Browse files
authored
feat(experimentation): sync warehouse connection environment keys to ingestion redis (#7588)
1 parent 537a7d6 commit 7d3b541

12 files changed

Lines changed: 271 additions & 1 deletion

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)

api/experimentation/models.py

Lines changed: 22 additions & 1 deletion
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):
@@ -47,3 +56,15 @@ class Meta:
4756
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/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")

infrastructure/aws/production/ecs-task-definition-admin-api.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,10 @@
228228
{
229229
"name": "DASHBOARD_ENDPOINTS_SENTRY_TRACE_SAMPLE_RATE",
230230
"value": "0.5"
231+
},
232+
{
233+
"name": "INGESTION_REDIS_URL",
234+
"value": "rediss://serverless-redis-cache-c4q8sw.serverless.euw2.cache.amazonaws.com:6379"
231235
}
232236
],
233237
"secrets": [

infrastructure/aws/production/ecs-task-definition-task-processor.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@
192192
{
193193
"name": "LOG_FORMAT",
194194
"value": "json"
195+
},
196+
{
197+
"name": "INGESTION_REDIS_URL",
198+
"value": "rediss://serverless-redis-cache-c4q8sw.serverless.euw2.cache.amazonaws.com:6379"
195199
}
196200
],
197201
"secrets": [

0 commit comments

Comments
 (0)