Skip to content

Commit 8283ecc

Browse files
committed
feat: Add a Redis example for FlagDefinitionCacheProvider
1 parent 6b39b62 commit 8283ecc

File tree

2 files changed

+144
-0
lines changed

2 files changed

+144
-0
lines changed

examples/redis_flag_cache.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""
2+
Redis-based distributed cache for PostHog feature flag definitions.
3+
4+
This example demonstrates how to implement a FlagDefinitionCacheProvider
5+
using Redis for multi-instance deployments (leader election pattern).
6+
7+
Usage:
8+
import redis
9+
from posthog import Posthog
10+
11+
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
12+
cache = RedisFlagCache(redis_client, service_key="my-service")
13+
14+
posthog = Posthog(
15+
"<project_api_key>",
16+
personal_api_key="<personal_api_key>",
17+
flag_definition_cache_provider=cache,
18+
)
19+
20+
Requirements:
21+
pip install redis
22+
"""
23+
24+
import json
25+
import uuid
26+
27+
from posthog import FlagDefinitionCacheData, FlagDefinitionCacheProvider
28+
from redis import Redis
29+
from typing import Optional
30+
31+
32+
class RedisFlagCache(FlagDefinitionCacheProvider):
33+
"""
34+
A distributed cache for PostHog feature flag definitions using Redis.
35+
36+
In a multi-instance deployment (e.g., multiple serverless functions or containers),
37+
we want only ONE instance to poll PostHog for flag updates, while all instances
38+
share the cached results. This prevents N instances from making N redundant API calls.
39+
40+
The implementation uses leader election:
41+
- One instance "wins" and becomes responsible for fetching
42+
- Other instances read from the shared cache
43+
- If the leader dies, the lock expires (TTL) and another instance takes over
44+
45+
Uses Lua scripts for atomic operations, following Redis distributed lock best practices:
46+
https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/
47+
"""
48+
49+
LOCK_TTL_MS = 60 * 1000 # 60 seconds, should be longer than the flags poll interval
50+
CACHE_TTL_SECONDS = 60 * 60 * 24 # 24 hours
51+
52+
# Lua script: acquire lock if free, or extend if we own it
53+
_LUA_TRY_LEAD = """
54+
local current = redis.call('GET', KEYS[1])
55+
if current == false then
56+
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
57+
return 1
58+
elseif current == ARGV[1] then
59+
redis.call('PEXPIRE', KEYS[1], ARGV[2])
60+
return 1
61+
end
62+
return 0
63+
"""
64+
65+
# Lua script: release lock only if we own it
66+
_LUA_STOP_LEAD = """
67+
if redis.call('GET', KEYS[1]) == ARGV[1] then
68+
return redis.call('DEL', KEYS[1])
69+
end
70+
return 0
71+
"""
72+
73+
def __init__(self, redis: Redis, service_key: str):
74+
"""
75+
Initialize the Redis flag cache.
76+
77+
Args:
78+
redis: A redis-py client instance. Must be configured with
79+
decode_responses=True for correct string handling.
80+
service_key: A unique identifier for this service/environment.
81+
Used to scope Redis keys, allowing multiple services
82+
or environments to share the same Redis instance.
83+
Examples: "my-api-prod", "checkout-service", "staging".
84+
85+
Redis Keys Created:
86+
- posthog:flags:{service_key} - Cached flag definitions (JSON)
87+
- posthog:flags:{service_key}:lock - Leader election lock
88+
89+
Example:
90+
redis_client = redis.Redis(
91+
host='localhost',
92+
port=6379,
93+
decode_responses=True
94+
)
95+
cache = RedisFlagCache(redis_client, service_key="my-api-prod")
96+
"""
97+
self._redis = redis
98+
self._cache_key = f"posthog:flags:{service_key}"
99+
self._lock_key = f"posthog:flags:{service_key}:lock"
100+
self._instance_id = str(uuid.uuid4())
101+
self._try_lead = self._redis.register_script(self._LUA_TRY_LEAD)
102+
self._stop_lead = self._redis.register_script(self._LUA_STOP_LEAD)
103+
104+
def get_flag_definitions(self) -> Optional[FlagDefinitionCacheData]:
105+
"""
106+
Retrieve cached flag definitions from Redis.
107+
108+
Returns:
109+
Cached flag definitions if available, None otherwise.
110+
"""
111+
cached = self._redis.get(self._cache_key)
112+
return json.loads(cached) if cached else None
113+
114+
def should_fetch_flag_definitions(self) -> bool:
115+
"""
116+
Determines if this instance should fetch flag definitions from PostHog.
117+
118+
Atomically either:
119+
- Acquires the lock if no one holds it, OR
120+
- Extends the lock TTL if we already hold it
121+
122+
Returns:
123+
True if this instance is the leader and should fetch, False otherwise.
124+
"""
125+
result = self._try_lead(
126+
keys=[self._lock_key],
127+
args=[self._instance_id, self.LOCK_TTL_MS],
128+
)
129+
return result == 1
130+
131+
def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None:
132+
"""
133+
Store fetched flag definitions in Redis.
134+
135+
Args:
136+
data: The flag definitions to cache.
137+
"""
138+
self._redis.set(self._cache_key, json.dumps(data), ex=self.CACHE_TTL_SECONDS)
139+
140+
def shutdown(self) -> None:
141+
"""
142+
Release leadership if we hold it. Safe to call even if not the leader.
143+
"""
144+
self._stop_lead(keys=[self._lock_key], args=[self._instance_id])

0 commit comments

Comments
 (0)