Skip to content

Commit a44e0be

Browse files
authored
feat: Support async flag definition cache providers (#626)
* feat: support async flag definition cache providers * refactor: remove redundant async runner lock * style: format async runner
1 parent 481e186 commit a44e0be

6 files changed

Lines changed: 535 additions & 22 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: minor
3+
---
4+
5+
Add async flag definition cache providers

‎examples/async_redis_flag_cache.py‎

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

‎posthog/_async_utils.py‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import asyncio
2+
import threading
3+
from collections.abc import Awaitable
4+
from typing import Any
5+
6+
7+
class _BackgroundEventLoopRunner:
8+
"""Run awaitables to completion on a reusable background event loop."""
9+
10+
def __init__(self) -> None:
11+
self._loop: asyncio.AbstractEventLoop | None = None
12+
self._thread: threading.Thread | None = None
13+
self._started = threading.Event()
14+
self._lock = threading.Lock()
15+
16+
def run(self, awaitable: Awaitable[Any]) -> Any:
17+
loop = self._ensure_loop()
18+
future = asyncio.run_coroutine_threadsafe(self._await_result(awaitable), loop)
19+
return future.result()
20+
21+
def close(self) -> None:
22+
with self._lock:
23+
loop = self._loop
24+
thread = self._thread
25+
self._loop = None
26+
self._thread = None
27+
28+
if loop is None or thread is None or loop.is_closed():
29+
return
30+
31+
if thread is threading.current_thread():
32+
loop.call_soon(loop.stop)
33+
return
34+
35+
loop.call_soon_threadsafe(loop.stop)
36+
thread.join()
37+
38+
@staticmethod
39+
async def _await_result(awaitable: Awaitable[Any]) -> Any:
40+
return await awaitable
41+
42+
def _ensure_loop(self) -> asyncio.AbstractEventLoop:
43+
with self._lock:
44+
if (
45+
self._loop is not None
46+
and self._thread is not None
47+
and self._thread.is_alive()
48+
and not self._loop.is_closed()
49+
):
50+
return self._loop
51+
52+
self._started.clear()
53+
self._thread = threading.Thread(
54+
target=self._run_loop,
55+
name="PostHogBackgroundEventLoopRunner",
56+
daemon=True,
57+
)
58+
self._thread.start()
59+
60+
self._started.wait()
61+
with self._lock:
62+
assert self._loop is not None
63+
return self._loop
64+
65+
def _run_loop(self) -> None:
66+
loop = asyncio.new_event_loop()
67+
asyncio.set_event_loop(loop)
68+
with self._lock:
69+
self._loop = loop
70+
self._started.set()
71+
72+
try:
73+
loop.run_forever()
74+
finally:
75+
pending = asyncio.all_tasks(loop)
76+
for task in pending:
77+
task.cancel()
78+
if pending:
79+
loop.run_until_complete(
80+
asyncio.gather(*pending, return_exceptions=True)
81+
)
82+
loop.run_until_complete(loop.shutdown_asyncgens())
83+
loop.run_until_complete(loop.shutdown_default_executor())
84+
asyncio.set_event_loop(None)
85+
loop.close()

‎posthog/client.py‎

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import atexit
2+
import inspect
23
import json
34
import logging
45
import os
56
import sys
7+
import threading
68
import warnings
79
import weakref
810
from datetime import datetime, timedelta, timezone
@@ -11,6 +13,7 @@
1113

1214
from typing_extensions import Unpack
1315

16+
from posthog._async_utils import _BackgroundEventLoopRunner
1417
from posthog.args import ID_TYPES, ExceptionArg, OptionalCaptureArgs, OptionalSetArgs
1518
from posthog.consumer import Consumer
1619
from posthog.contexts import (
@@ -305,6 +308,10 @@ def __init__(
305308
self.flag_definition_version = 0
306309
self._flags_etag: Optional[str] = None
307310
self._flag_definition_cache_provider = flag_definition_cache_provider
311+
self._flag_definition_cache_provider_async_runner: Optional[
312+
_BackgroundEventLoopRunner
313+
] = None
314+
self._flag_definition_cache_provider_async_runner_lock = threading.Lock()
308315
self.disabled = disabled or not self.api_key
309316
self.disable_geoip = disable_geoip
310317
self.historical_migration = historical_migration
@@ -1246,6 +1253,10 @@ def _reinit_after_fork(self):
12461253
else:
12471254
self.poller = None
12481255

1256+
# Async runner threads do not survive fork(); recreate lazily on next async cache call.
1257+
self._flag_definition_cache_provider_async_runner = None
1258+
self._flag_definition_cache_provider_async_runner_lock = threading.Lock()
1259+
12491260
# If using Redis cache, we must reinitialize to get a fresh connection (fork-safe).
12501261
# If using Memory cache, we keep it as-is to benefit from the inherited warm cache.
12511262
if isinstance(self.flag_cache, RedisFlagCache):
@@ -1373,11 +1384,7 @@ def join(self):
13731384
self.poller.stop()
13741385

13751386
# Shutdown the cache provider (release locks, cleanup)
1376-
if self._flag_definition_cache_provider:
1377-
try:
1378-
self._flag_definition_cache_provider.shutdown()
1379-
except Exception as e:
1380-
self.log.error(f"[FEATURE FLAGS] Cache provider shutdown error: {e}")
1387+
self._shutdown_flag_definition_cache_provider()
13811388

13821389
def shutdown(self):
13831390
"""
@@ -1394,6 +1401,33 @@ def shutdown(self):
13941401
if self.exception_capture:
13951402
self.exception_capture.close()
13961403

1404+
def _resolve_flag_definition_cache_provider_result(self, result):
1405+
if not inspect.isawaitable(result):
1406+
return result
1407+
1408+
with self._flag_definition_cache_provider_async_runner_lock:
1409+
if self._flag_definition_cache_provider_async_runner is None:
1410+
self._flag_definition_cache_provider_async_runner = (
1411+
_BackgroundEventLoopRunner()
1412+
)
1413+
return self._flag_definition_cache_provider_async_runner.run(result)
1414+
1415+
def _shutdown_flag_definition_cache_provider(self):
1416+
if not self._flag_definition_cache_provider:
1417+
return
1418+
1419+
try:
1420+
self._resolve_flag_definition_cache_provider_result(
1421+
self._flag_definition_cache_provider.shutdown()
1422+
)
1423+
except Exception as e:
1424+
self.log.error(f"[FEATURE FLAGS] Cache provider shutdown error: {e}")
1425+
finally:
1426+
with self._flag_definition_cache_provider_async_runner_lock:
1427+
if self._flag_definition_cache_provider_async_runner:
1428+
self._flag_definition_cache_provider_async_runner.close()
1429+
self._flag_definition_cache_provider_async_runner = None
1430+
13971431
def _update_flag_state(
13981432
self, data: FlagDefinitionCacheData, old_flags_by_key: Optional[dict] = None
13991433
) -> None:
@@ -1416,7 +1450,7 @@ def _load_feature_flags(self):
14161450
should_fetch = True
14171451
if self._flag_definition_cache_provider:
14181452
try:
1419-
should_fetch = (
1453+
should_fetch = self._resolve_flag_definition_cache_provider_result(
14201454
self._flag_definition_cache_provider.should_fetch_flag_definitions()
14211455
)
14221456
except Exception as e:
@@ -1429,7 +1463,7 @@ def _load_feature_flags(self):
14291463
# If not fetching, try to get from cache
14301464
if not should_fetch and self._flag_definition_cache_provider:
14311465
try:
1432-
cached_data = (
1466+
cached_data = self._resolve_flag_definition_cache_provider_result(
14331467
self._flag_definition_cache_provider.get_flag_definitions()
14341468
)
14351469
if cached_data:
@@ -1500,12 +1534,14 @@ def _fetch_feature_flags_from_api(self):
15001534
# Store in external cache if provider is configured
15011535
if self._flag_definition_cache_provider:
15021536
try:
1503-
self._flag_definition_cache_provider.on_flag_definitions_received(
1504-
{
1505-
"flags": self.feature_flags or [],
1506-
"group_type_mapping": self.group_type_mapping or {},
1507-
"cohorts": self.cohorts or {},
1508-
}
1537+
self._resolve_flag_definition_cache_provider_result(
1538+
self._flag_definition_cache_provider.on_flag_definitions_received(
1539+
{
1540+
"flags": self.feature_flags or [],
1541+
"group_type_mapping": self.group_type_mapping or {},
1542+
"cohorts": self.cohorts or {},
1543+
}
1544+
)
15091545
)
15101546
except Exception as e:
15111547
self.log.error(f"[FEATURE FLAGS] Cache provider store error: {e}")

0 commit comments

Comments
 (0)