Skip to content

Commit 0bb6428

Browse files
authored
Merge pull request #8772 from Couchers-org/backend/feature/feature-flag-disk-cache
Backend: persist feature flags to a disk cache
2 parents c880794 + e3306f5 commit 0bb6428

8 files changed

Lines changed: 160 additions & 2 deletions

File tree

app/backend.dev.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ EXPERIMENTATION_ENABLED=0
8989
EXPERIMENTATION_PASS_ALL_GATES=1
9090
GROWTHBOOK_API_HOST=https://gbapi.couchershq.org
9191
GROWTHBOOK_CLIENT_KEY=sdk-cGmljeXu1BCzHffE
92+
GROWTHBOOK_CACHE_PATH=growthbook-features-cache.json
9293

9394
# Moderation auto-approval deadline in seconds (0 to disable)
9495
MODERATION_AUTO_APPROVE_DEADLINE_SECONDS=10

app/backend/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@ htmlcov/
1919

2020
# test artifacts
2121
test_artifacts/
22+
23+
# GrowthBook feature-flag disk cache (GROWTHBOOK_CACHE_PATH)
24+
growthbook-features-cache.json

app/backend/src/couchers/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@
122122
# GrowthBook SDK configuration
123123
("GROWTHBOOK_API_HOST", str, "https://cdn.growthbook.io"),
124124
("GROWTHBOOK_CLIENT_KEY", str, ""),
125+
# Disk path for the last-known-good feature payload, used as a cold-start fallback when GrowthBook
126+
# is unreachable. Required when experimentation is enabled so we never start on in-code defaults.
127+
("GROWTHBOOK_CACHE_PATH", str, ""),
125128
# Moderation auto-approval deadline in seconds (0 to disable auto-approval)
126129
("MODERATION_AUTO_APPROVE_DEADLINE_SECONDS", int),
127130
# User ID of the bot user for automated moderation actions
@@ -173,6 +176,8 @@ def check_config(cfg: dict[str, Any]) -> None:
173176
if cfg["EXPERIMENTATION_ENABLED"]:
174177
if not cfg["GROWTHBOOK_CLIENT_KEY"]:
175178
raise Exception("No GrowthBook client key but experimentation enabled")
179+
if not cfg["GROWTHBOOK_CACHE_PATH"]:
180+
raise Exception("No GrowthBook cache path but experimentation enabled")
176181

177182

178183
def make_config() -> dict[str, Any]:

app/backend/src/couchers/experimentation.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@
1919
import json
2020
import logging
2121
import threading
22+
import time
2223
from collections.abc import Callable
24+
from pathlib import Path
25+
from tempfile import NamedTemporaryFile
2326
from typing import Any
2427

2528
import urllib3
@@ -42,12 +45,19 @@
4245
_state_lock = threading.Lock()
4346
_refresh_stop = threading.Event()
4447
_refresh_thread: threading.Thread | None = None
48+
# Unix time of the last successful pull from GrowthBook (None until the first success). Set when we
49+
# load from the API or seed from the disk cache; drives the staleness metric.
50+
_last_fetch_time: float | None = None
4551

4652

4753
class ExperimentationNotInitializedError(Exception):
4854
"""Raised when experimentation functions are called before initialization."""
4955

5056

57+
class GrowthBookUnavailableError(Exception):
58+
"""Raised at startup when features can't be fetched and there's no usable disk cache to fall back on."""
59+
60+
5161
def _fetch_features() -> dict[str, Any] | None:
5262
"""Fetch the GrowthBook feature payload over HTTP. Returns None on failure."""
5363
api_host = config["GROWTHBOOK_API_HOST"].rstrip("/")
@@ -74,13 +84,48 @@ def _apply_response(response: dict[str, Any]) -> None:
7484
_state["savedGroups"] = response.get("savedGroups", {})
7585

7686

87+
def _set_last_fetch_time(when: float) -> None:
88+
global _last_fetch_time
89+
_last_fetch_time = when
90+
91+
92+
def seconds_since_last_fetch() -> float | None:
93+
"""Seconds since the last successful pull, or None if never pulled. Drives the staleness metric."""
94+
when = _last_fetch_time
95+
if when is None:
96+
return None
97+
return max(0.0, time.time() - when)
98+
99+
100+
def _write_cache(response: dict[str, Any]) -> None:
101+
path = Path(config["GROWTHBOOK_CACHE_PATH"])
102+
data = json.dumps({"fetched_at": time.time(), "response": response})
103+
# Temp file alongside the target then rename: rename is atomic within a filesystem, so a reader
104+
# never sees a half-written cache.
105+
with NamedTemporaryFile("w", dir=path.parent, prefix=".growthbook-cache-", suffix=".tmp", delete=False) as f:
106+
f.write(data)
107+
tmp = Path(f.name)
108+
tmp.replace(path)
109+
110+
111+
def _read_cache() -> tuple[dict[str, Any], float] | None:
112+
"""(response, fetched_at), or None if no cache file exists yet. A corrupt file raises."""
113+
path = Path(config["GROWTHBOOK_CACHE_PATH"])
114+
if not path.exists():
115+
return None
116+
payload = json.loads(path.read_text())
117+
return payload["response"], payload["fetched_at"]
118+
119+
77120
def _refresh_loop() -> None:
78121
while not _refresh_stop.wait(_REFRESH_INTERVAL_SECONDS):
79122
response = _fetch_features()
80123
if response is not None:
81124
_apply_response(response)
125+
_write_cache(response)
126+
_set_last_fetch_time(time.time())
82127
logger.debug("GrowthBook features refreshed")
83-
# On failure, keep last-known-good state and try again next tick.
128+
# On a failed fetch, keep last-known-good state and retry next tick; the staleness metric climbs.
84129

85130

86131
def setup_experimentation() -> None:
@@ -107,6 +152,24 @@ def setup_experimentation() -> None:
107152
response = _fetch_features()
108153
if response is not None:
109154
_apply_response(response)
155+
_write_cache(response)
156+
_set_last_fetch_time(time.time())
157+
logger.info("GrowthBook features loaded from API")
158+
else:
159+
# Unreachable at startup: fall back to the disk cache rather than booting on in-code defaults.
160+
cached = _read_cache()
161+
if cached is None:
162+
raise GrowthBookUnavailableError(
163+
"Could not fetch features from GrowthBook and no disk cache is available - refusing to "
164+
"start on in-code feature-flag defaults"
165+
)
166+
cached_response, fetched_at = cached
167+
_apply_response(cached_response)
168+
_set_last_fetch_time(fetched_at)
169+
logger.warning(
170+
"GrowthBook unavailable at startup; loaded features from disk cache (%.0fs old)",
171+
max(0.0, time.time() - fetched_at),
172+
)
110173

111174
with _state_lock:
112175
smoke_gb = GrowthBook(features=_state["features"], savedGroups=_state["savedGroups"])

app/backend/src/couchers/metrics.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from sqlalchemy.sql import distinct, func
2020
from sqlalchemy.sql.selectable import Select
2121

22+
from couchers import experimentation
2223
from couchers.db import session_scope
2324
from couchers.helpers.completed_profile import has_completed_profile_expression
2425
from couchers.materialized_views import ClusterSubscriptionCount
@@ -633,6 +634,20 @@ def observe_moderation_queue_resolution_time(
633634
)
634635

635636

637+
# Recomputed at scrape time via the hacky-gauge mechanism, so it reflects live age. 0 when disabled
638+
# or never pulled.
639+
def _feature_flags_staleness_seconds() -> float:
640+
return experimentation.seconds_since_last_fetch() or 0.0
641+
642+
643+
feature_flags_staleness_gauge: Gauge = Gauge(
644+
"couchers_feature_flags_staleness_seconds",
645+
"Seconds since feature flags were last successfully fetched from GrowthBook",
646+
multiprocess_mode="mostrecent",
647+
)
648+
_set_hacky_gauges_funcs.append((feature_flags_staleness_gauge, _feature_flags_staleness_seconds))
649+
650+
636651
def create_prometheus_server(port: int) -> Any:
637652
"""custom start method to fix problem descrbied in https://github.com/prometheus/client_python/issues/155"""
638653

app/backend/src/tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ def testconfig():
235235
config["EXPERIMENTATION_PASS_ALL_GATES"] = True
236236
config["GROWTHBOOK_API_HOST"] = "https://cdn.growthbook.io"
237237
config["GROWTHBOOK_CLIENT_KEY"] = ""
238+
config["GROWTHBOOK_CACHE_PATH"] = ""
238239

239240
# Moderation auto-approval deadline - 0 disables, set in tests that need it
240241
config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 0

app/backend/src/tests/test_experimentation.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import json
2+
3+
import pytest
14
from growthbook.common_types import FeatureResult
25
from sqlalchemy import select
36

47
from couchers import experimentation
58
from couchers.config import config
69
from couchers.context import make_background_user_context, make_logged_out_context
710
from couchers.db import session_scope
8-
from couchers.experimentation import _record_feature_usage
11+
from couchers.experimentation import GrowthBookUnavailableError, _record_feature_usage, setup_experimentation
912
from couchers.i18n import LocalizationContext
1013
from couchers.models.logging import ExperimentExposure, FeatureUsage
1114
from couchers.proto import bugs_pb2
@@ -145,3 +148,68 @@ def test_global_evaluation_gets_global_force_on_flag(feature_flags):
145148

146149
def test_global_evaluation_unknown_feature_returns_in_code_default(feature_flags):
147150
assert experimentation.get_global_string_value("does_not_exist", "my_default") == "my_default"
151+
152+
153+
@pytest.fixture
154+
def setup_isolation(monkeypatch, tmp_path):
155+
"""Run setup_experimentation() against a clean module state and a tmp cache path, and make sure the
156+
background refresh thread it starts is stopped afterwards."""
157+
monkeypatch.setattr(experimentation, "_initialized", False)
158+
monkeypatch.setattr(experimentation, "_last_fetch_time", None)
159+
monkeypatch.setattr(experimentation, "_state", {"features": {}, "savedGroups": {}})
160+
monkeypatch.setitem(config, "EXPERIMENTATION_ENABLED", True)
161+
monkeypatch.setitem(config, "GROWTHBOOK_CACHE_PATH", str(tmp_path / "cache.json"))
162+
yield tmp_path / "cache.json"
163+
experimentation._refresh_stop.set()
164+
if experimentation._refresh_thread is not None:
165+
experimentation._refresh_thread.join(timeout=5)
166+
experimentation._refresh_stop.clear()
167+
experimentation._refresh_thread = None
168+
169+
170+
def test_setup_writes_cache_and_records_fetch_time(setup_isolation, monkeypatch):
171+
cache = setup_isolation
172+
payload = {"features": {"f": {"defaultValue": True}}, "savedGroups": {}}
173+
monkeypatch.setattr(experimentation, "_fetch_features", lambda: payload)
174+
175+
setup_experimentation()
176+
177+
assert experimentation._state["features"] == {"f": {"defaultValue": True}}
178+
assert experimentation.seconds_since_last_fetch() is not None
179+
written = json.loads(cache.read_text())
180+
assert written["response"] == payload
181+
assert "fetched_at" in written
182+
183+
184+
def test_setup_falls_back_to_disk_cache_when_fetch_fails(setup_isolation, monkeypatch):
185+
cache = setup_isolation
186+
cached_payload = {"features": {"cached": {"defaultValue": "x"}}, "savedGroups": {}}
187+
cache.write_text(json.dumps({"fetched_at": 1000.0, "response": cached_payload}))
188+
monkeypatch.setattr(experimentation, "_fetch_features", lambda: None)
189+
190+
setup_experimentation()
191+
192+
assert experimentation._state["features"] == {"cached": {"defaultValue": "x"}}
193+
# fetch time reflects the cached pull time, so staleness is large immediately
194+
staleness = experimentation.seconds_since_last_fetch()
195+
assert staleness is not None and staleness > 0
196+
197+
198+
def test_setup_raises_when_fetch_fails_and_no_cache(setup_isolation, monkeypatch):
199+
monkeypatch.setattr(experimentation, "_fetch_features", lambda: None)
200+
201+
with pytest.raises(GrowthBookUnavailableError):
202+
setup_experimentation()
203+
204+
205+
def test_setup_raises_on_corrupt_cache(setup_isolation, monkeypatch):
206+
cache = setup_isolation
207+
cache.write_text("this is not json")
208+
monkeypatch.setattr(experimentation, "_fetch_features", lambda: None)
209+
210+
with pytest.raises(json.JSONDecodeError):
211+
setup_experimentation()
212+
213+
214+
def test_seconds_since_last_fetch_none_when_never_fetched(setup_isolation):
215+
assert experimentation.seconds_since_last_fetch() is None

app/docker-compose.prod.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ services:
2929
volumes:
3030
# auxiliary resources
3131
- "./data/aux/:/app/aux/:ro"
32+
# persistent GrowthBook feature-flag cache (GROWTHBOOK_CACHE_PATH), survives deploys
33+
- "./data/cache/:/data/cache/"
3234
expose:
3335
- 1751
3436
- 1753

0 commit comments

Comments
 (0)