Skip to content

Commit 161e3d1

Browse files
feat(Segment membership inspection PoC): Daily ClickHouse-backed per-env segment counts (#7464)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent a377140 commit 161e3d1

35 files changed

Lines changed: 1613 additions & 11 deletions

.github/workflows/update-flagsmith-environment.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ defaults:
99
run:
1010
working-directory: api
1111

12+
permissions:
13+
contents: read
14+
1215
jobs:
1316
update_server_defaults:
1417
runs-on: depot-ubuntu-latest

api/app/routers.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.db.models import Model
44

55
AnalyticsDatabaseName = Literal["analytics"]
6+
ClickHouseDatabaseName = Literal["clickhouse"]
67

78

89
class AnalyticsRouter:
@@ -38,3 +39,38 @@ def allow_migrate(self, db: str, app_label: str, **hints: Any) -> bool | None:
3839
if db == "analytics":
3940
return app_label in self.route_app_labels
4041
return None
42+
43+
44+
class ClickHouseRouter:
45+
route_app_labels = ["clickhouse"]
46+
47+
def db_for_read(
48+
self, model: type[Model], **hints: Any
49+
) -> ClickHouseDatabaseName | None:
50+
if model._meta.app_label in self.route_app_labels:
51+
return "clickhouse"
52+
return None
53+
54+
def db_for_write(
55+
self, model: type[Model], **hints: Any
56+
) -> ClickHouseDatabaseName | None:
57+
if model._meta.app_label in self.route_app_labels:
58+
return "clickhouse"
59+
return None
60+
61+
def allow_relation(self, obj1: Model, obj2: Model, **hints: Any) -> bool | None:
62+
# ClickHouse has no FKs and we don't expose CH-app models, so any
63+
# relation involving this app is forbidden.
64+
if (
65+
obj1._meta.app_label in self.route_app_labels
66+
or obj2._meta.app_label in self.route_app_labels
67+
):
68+
return False
69+
return None
70+
71+
def allow_migrate(self, db: str, app_label: str, **hints: Any) -> bool | None:
72+
if db == "clickhouse":
73+
return app_label in self.route_app_labels
74+
if app_label in self.route_app_labels:
75+
return False
76+
return None

api/app/settings/common.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@
118118
"features.workflows.core",
119119
"features.release_pipelines.core",
120120
"segments",
121+
"segment_membership",
122+
"clickhouse",
121123
"app",
122124
"e2etests",
123125
"simple_history",
@@ -1446,3 +1448,39 @@
14461448
PYLON_IDENTITY_VERIFICATION_SECRET = env.str("PYLON_IDENTITY_VERIFICATION_SECRET", None)
14471449

14481450
OSIC_UPDATE_BATCH_SIZE = env.int("OSIC_UPDATE_BATCH_SIZE", default=500)
1451+
1452+
# ClickHouse backs the segment_membership backfill and refresh tasks. Set
1453+
# CLICKHOUSE_URL (DSN form) or any CLICKHOUSE_HOST + discrete fields to enable.
1454+
# Discrete settings override the matching field parsed from the URL.
1455+
CLICKHOUSE_URL = env.str("CLICKHOUSE_URL", default=None)
1456+
CLICKHOUSE_HOST = env.str("CLICKHOUSE_HOST", default=None)
1457+
CLICKHOUSE_PORT = env.int("CLICKHOUSE_PORT", default=None)
1458+
CLICKHOUSE_USER = env.str("CLICKHOUSE_USER", default=None)
1459+
CLICKHOUSE_PASSWORD = env.str("CLICKHOUSE_PASSWORD", default=None)
1460+
CLICKHOUSE_DATABASE = env.str("CLICKHOUSE_DATABASE", default=None)
1461+
CLICKHOUSE_SECURE = env.bool("CLICKHOUSE_SECURE", default=None)
1462+
1463+
CLICKHOUSE_ENABLED = bool(CLICKHOUSE_URL or CLICKHOUSE_HOST)
1464+
1465+
# Always installed: the router fences the `clickhouse` app's migrations off
1466+
# the default Postgres database whether or not a CH alias is configured.
1467+
DATABASE_ROUTERS.append("app.routers.ClickHouseRouter")
1468+
1469+
if CLICKHOUSE_ENABLED:
1470+
_clickhouse_db: dict[str, Any] = {
1471+
"ENGINE": "clickhouse_backend.backend",
1472+
"HOST": CLICKHOUSE_HOST,
1473+
"PORT": CLICKHOUSE_PORT,
1474+
"USER": CLICKHOUSE_USER,
1475+
"PASSWORD": CLICKHOUSE_PASSWORD,
1476+
"NAME": CLICKHOUSE_DATABASE,
1477+
"OPTIONS": {
1478+
"dsn": CLICKHOUSE_URL,
1479+
"secure": CLICKHOUSE_SECURE,
1480+
"settings": {
1481+
# ClickHouse Cloud 25.12 requires this for `JSON`-column DDL.
1482+
"allow_experimental_json_type": 1,
1483+
},
1484+
},
1485+
}
1486+
DATABASES["clickhouse"] = _clickhouse_db # type: ignore[assignment]

api/clickhouse/__init__.py

Whitespace-only changes.

api/clickhouse/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class ClickHouseConfig(AppConfig):
5+
name = "clickhouse"
6+
label = "clickhouse"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from django.db import migrations
2+
3+
4+
_SCHEMA_DDL = """\
5+
CREATE TABLE IF NOT EXISTS IDENTITIES (
6+
environment_id String,
7+
-- (environment_id, identifier) is the natural unique key in Flagsmith's
8+
-- identity model — dedupes ReplacingMergeTree without a synthetic id.
9+
identifier String,
10+
identity_key String,
11+
-- Stored per top-level key as typed subcolumns; SQL NULL for empty traits.
12+
traits JSON,
13+
-- ReplacingMergeTree version column; most-recent insert wins per PK.
14+
inserted_at DateTime DEFAULT now()
15+
)
16+
ENGINE = ReplacingMergeTree(inserted_at)
17+
ORDER BY (environment_id, identifier)
18+
"""
19+
20+
21+
class Migration(migrations.Migration):
22+
# ClickHouse has no transactional DDL.
23+
atomic = False
24+
initial = True
25+
26+
operations = [
27+
migrations.RunSQL(_SCHEMA_DDL, reverse_sql="DROP TABLE IF EXISTS IDENTITIES"),
28+
]

api/clickhouse/migrations/__init__.py

Whitespace-only changes.

api/clickhouse/models.py

Whitespace-only changes.

api/pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ dependencies = [
4444
"drf-writable-nested>=0.6.2,<0.7.0",
4545
"django-filter>=2.4.0,<2.5.0",
4646
"flagsmith-flag-engine>=10.1.0,<11.0.0",
47+
"flagsmith-sql-flag-engine>=0.1.0,<0.2.0",
48+
"django-clickhouse-backend>=1.4,<2.0",
4749
"boto3>=1.35.95,<1.36.0",
4850
"slack-sdk>=3.9.0,<3.10.0",
4951
"asgiref>=3.8.1,<3.9.0",
@@ -71,7 +73,7 @@ dependencies = [
7173
"hubspot-api-client>=12.0.0,<13.0.0",
7274
"djangorestframework-dataclasses>=1.3.1,<2.0.0",
7375
"pyotp>=2.9.0,<3.0.0",
74-
"flagsmith-common[common-core,flagsmith-schemas,task-processor]>=3.9.0,<4",
76+
"flagsmith-common[common-core,flagsmith-schemas,task-processor]>=3.9.1,<4",
7577
"django-stubs>=5.1.3,<6.0.0",
7678
"tzdata>=2024.1,<2025.0.0",
7779
"djangorestframework-simplejwt>=5.5.1,<6.0.0",
@@ -541,6 +543,10 @@ ignore_missing_imports = true
541543
module = ["openfeature_flagsmith.*"]
542544
ignore_missing_imports = true
543545

546+
[[tool.mypy.overrides]]
547+
module = ["clickhouse_backend.*", "clickhouse_driver.*"]
548+
ignore_missing_imports = true
549+
544550
[[tool.mypy.overrides]]
545551
module = ["scim.*"]
546552
ignore_missing_imports = true

api/scripts/run-docker.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ run_task_processor() {
4545
if [ -n "$TASK_PROCESSOR_DATABASE_URL" ] || [ -n "$TASK_PROCESSOR_DATABASE_NAME" ]; then
4646
waitfordb --waitfor 30 --migrations --database task_processor
4747
fi
48+
if [ -n "$CLICKHOUSE_URL" ] || [ -n "$CLICKHOUSE_HOST" ]; then
49+
waitfordb --waitfor 30 --migrations --database clickhouse
50+
fi
4851
exec flagsmith start \
4952
--bind 0.0.0.0:8000 \
5053
--access-logfile $ACCESS_LOG_LOCATION \
@@ -68,6 +71,12 @@ migrate_task_processor_db(){
6871
fi
6972
python manage.py migrate --database task_processor
7073
}
74+
migrate_clickhouse_db(){
75+
if [ -z "$CLICKHOUSE_URL" ] && [ -z "$CLICKHOUSE_HOST" ]; then
76+
return 0
77+
fi
78+
python manage.py migrate --database clickhouse
79+
}
7180
bootstrap(){
7281
python manage.py bootstrap
7382
}
@@ -81,17 +90,20 @@ if [ "$1" = "migrate" ]; then
8190
migrate
8291
migrate_analytics_db
8392
migrate_task_processor_db
93+
migrate_clickhouse_db
8494
elif [ "$1" = "serve" ]; then
8595
serve
8696
elif [ "$1" = "run-task-processor" ]; then
8797
migrate
8898
migrate_analytics_db
8999
migrate_task_processor_db
100+
migrate_clickhouse_db
90101
run_task_processor
91102
elif [ "$1" = "migrate-and-serve" ]; then
92103
migrate
93104
migrate_analytics_db
94105
migrate_task_processor_db
106+
migrate_clickhouse_db
95107
bootstrap
96108
serve
97109
else

0 commit comments

Comments
 (0)