redis-feature-flags 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- redis_feature_flags-0.1.0/.gitignore +59 -0
- redis_feature_flags-0.1.0/PKG-INFO +32 -0
- redis_feature_flags-0.1.0/README.md +9 -0
- redis_feature_flags-0.1.0/pyproject.toml +52 -0
- redis_feature_flags-0.1.0/redis_feature_flags/__init__.py +20 -0
- redis_feature_flags-0.1.0/redis_feature_flags/cache.py +121 -0
- redis_feature_flags-0.1.0/redis_feature_flags/client.py +173 -0
- redis_feature_flags-0.1.0/redis_feature_flags/cohorts.py +153 -0
- redis_feature_flags-0.1.0/redis_feature_flags/evaluator.py +154 -0
- redis_feature_flags-0.1.0/redis_feature_flags/exceptions.py +137 -0
- redis_feature_flags-0.1.0/redis_feature_flags/schema.py +31 -0
- redis_feature_flags-0.1.0/redis_feature_flags/utils.py +25 -0
- redis_feature_flags-0.1.0/tests/__init__.py +0 -0
- redis_feature_flags-0.1.0/tests/e2e/README.md +72 -0
- redis_feature_flags-0.1.0/tests/e2e/__init__.py +0 -0
- redis_feature_flags-0.1.0/tests/e2e/conftest.py +66 -0
- redis_feature_flags-0.1.0/tests/e2e/constants.py +3 -0
- redis_feature_flags-0.1.0/tests/e2e/test_cache.py +0 -0
- redis_feature_flags-0.1.0/tests/e2e/test_cli.py +396 -0
- redis_feature_flags-0.1.0/tests/e2e/test_cohorts.py +234 -0
- redis_feature_flags-0.1.0/tests/e2e/test_environments.py +182 -0
- redis_feature_flags-0.1.0/tests/e2e/test_evaluation.py +168 -0
- redis_feature_flags-0.1.0/tests/e2e/test_flag_lifecycle.py +427 -0
- redis_feature_flags-0.1.0/tests/test_cache.py +253 -0
- redis_feature_flags-0.1.0/tests/test_client.py +195 -0
- redis_feature_flags-0.1.0/tests/test_cohorts.py +207 -0
- redis_feature_flags-0.1.0/tests/test_evaluator.py +332 -0
- redis_feature_flags-0.1.0/tests/test_exceptions.py +74 -0
- redis_feature_flags-0.1.0/tests/test_schema.py +98 -0
- redis_feature_flags-0.1.0/tests/test_utils.py +109 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg
|
|
8
|
+
*.egg-info/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
eggs/
|
|
12
|
+
parts/
|
|
13
|
+
var/
|
|
14
|
+
sdist/
|
|
15
|
+
develop-eggs/
|
|
16
|
+
.installed.cfg
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
wheels/
|
|
20
|
+
|
|
21
|
+
# Virtual environment
|
|
22
|
+
.venv/
|
|
23
|
+
venv/
|
|
24
|
+
ENV/
|
|
25
|
+
env/
|
|
26
|
+
|
|
27
|
+
# Testing
|
|
28
|
+
.pytest_cache/
|
|
29
|
+
.coverage
|
|
30
|
+
htmlcov/
|
|
31
|
+
coverage.xml
|
|
32
|
+
*.cover
|
|
33
|
+
|
|
34
|
+
# MyPy
|
|
35
|
+
.mypy_cache/
|
|
36
|
+
.dmypy.json
|
|
37
|
+
dmypy.json
|
|
38
|
+
|
|
39
|
+
# Ruff
|
|
40
|
+
.ruff_cache/
|
|
41
|
+
|
|
42
|
+
# IDE
|
|
43
|
+
.vscode/
|
|
44
|
+
.idea/
|
|
45
|
+
*.swp
|
|
46
|
+
*.swo
|
|
47
|
+
.DS_Store
|
|
48
|
+
|
|
49
|
+
# Environment variables
|
|
50
|
+
.env
|
|
51
|
+
.env.local
|
|
52
|
+
|
|
53
|
+
# Redis
|
|
54
|
+
dump.rdb
|
|
55
|
+
appendonly.aof
|
|
56
|
+
|
|
57
|
+
# Distribution
|
|
58
|
+
*.tar.gz
|
|
59
|
+
*.whl
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: redis-feature-flags
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Feature flags backed by Redis. No new server. No monthly bill.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: feature-flags,feature-toggles,redis,rollout
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Requires-Dist: redis>=4.0.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: black; extra == 'dev'
|
|
17
|
+
Requires-Dist: fakeredis>=2.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# redis-feature-flags
|
|
25
|
+
|
|
26
|
+
Feature flags backed by Redis.
|
|
27
|
+
No new server. No new database. No monthly bill.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
# Python
|
|
32
|
+
pip install redis-feature-flags
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "redis-feature-flags"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Feature flags backed by Redis. No new server. No monthly bill."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["redis", "feature-flags", "feature-toggles", "rollout"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3.9",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"redis>=4.0.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest>=7.0",
|
|
28
|
+
"pytest-cov>=4.0",
|
|
29
|
+
"fakeredis>=2.0",
|
|
30
|
+
"black",
|
|
31
|
+
"mypy",
|
|
32
|
+
"ruff",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
testpaths = ["tests"]
|
|
37
|
+
addopts = "--cov=redis_feature_flags --cov-report=term-missing --cov-fail-under=90"
|
|
38
|
+
|
|
39
|
+
[tool.coverage.run]
|
|
40
|
+
source = ["redis_feature_flags"]
|
|
41
|
+
omit = ["tests/*"]
|
|
42
|
+
|
|
43
|
+
[tool.mypy]
|
|
44
|
+
python_version = "3.9"
|
|
45
|
+
strict = true
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 88
|
|
49
|
+
target-version = "py39"
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.wheel]
|
|
52
|
+
packages = ["redis_feature_flags"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .client import FeatureFlags
|
|
2
|
+
from .exceptions import (
|
|
3
|
+
RedisFlagError,
|
|
4
|
+
FlagNotFoundError,
|
|
5
|
+
CohortNotFoundError,
|
|
6
|
+
InvalidRolloutError,
|
|
7
|
+
RedisConnectionError,
|
|
8
|
+
SchemaVersionError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
__all__ = [
|
|
13
|
+
"FeatureFlags",
|
|
14
|
+
"RedisFlagError",
|
|
15
|
+
"FlagNotFoundError",
|
|
16
|
+
"CohortNotFoundError",
|
|
17
|
+
"InvalidRolloutError",
|
|
18
|
+
"RedisConnectionError",
|
|
19
|
+
"SchemaVersionError",
|
|
20
|
+
]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# redis_feature_flags/cache.py
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class CacheEntry:
|
|
11
|
+
"""
|
|
12
|
+
A single cached flag object.
|
|
13
|
+
Stores the raw flag data and when it was cached.
|
|
14
|
+
"""
|
|
15
|
+
data: Dict[str, Any]
|
|
16
|
+
cached_at: float = field(default_factory=time.monotonic)
|
|
17
|
+
|
|
18
|
+
def is_expired(self, ttl_seconds: int) -> bool:
|
|
19
|
+
"""Check if this entry is older than the TTL."""
|
|
20
|
+
age = time.monotonic() - self.cached_at
|
|
21
|
+
return age > ttl_seconds
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LocalCache:
|
|
25
|
+
"""
|
|
26
|
+
In-process cache for feature flag data.
|
|
27
|
+
|
|
28
|
+
Serves flag data from memory to avoid a Redis call
|
|
29
|
+
on every is_enabled() evaluation. Falls back to
|
|
30
|
+
serving stale data if Redis becomes unreachable.
|
|
31
|
+
|
|
32
|
+
Thread-safe — safe to use across multiple threads.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, ttl_seconds: int = 30):
|
|
36
|
+
"""
|
|
37
|
+
Args:
|
|
38
|
+
ttl_seconds: How long a cached flag is considered
|
|
39
|
+
fresh. Default 30 seconds.
|
|
40
|
+
After TTL expires, next read fetches
|
|
41
|
+
from Redis and refreshes the cache.
|
|
42
|
+
"""
|
|
43
|
+
self._ttl = ttl_seconds
|
|
44
|
+
self._store: Dict[str, CacheEntry] = {}
|
|
45
|
+
self._lock = threading.Lock()
|
|
46
|
+
|
|
47
|
+
def get(self, key: str) -> Optional[Dict[str, Any]]:
|
|
48
|
+
"""
|
|
49
|
+
Get a cached flag if it exists and is not expired.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Flag data dict if cached and fresh.
|
|
53
|
+
None if not cached or expired.
|
|
54
|
+
"""
|
|
55
|
+
with self._lock:
|
|
56
|
+
entry = self._store.get(key)
|
|
57
|
+
if entry is None:
|
|
58
|
+
return None
|
|
59
|
+
if entry.is_expired(self._ttl):
|
|
60
|
+
return None
|
|
61
|
+
return entry.data
|
|
62
|
+
|
|
63
|
+
def get_stale(self, key: str) -> Optional[Dict[str, Any]]:
|
|
64
|
+
"""
|
|
65
|
+
Get a cached flag even if expired.
|
|
66
|
+
Used as fallback when Redis is unreachable.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Flag data dict if it exists in cache at all.
|
|
70
|
+
None if never cached.
|
|
71
|
+
"""
|
|
72
|
+
with self._lock:
|
|
73
|
+
entry = self._store.get(key)
|
|
74
|
+
if entry is None:
|
|
75
|
+
return None
|
|
76
|
+
return entry.data
|
|
77
|
+
|
|
78
|
+
def set(self, key: str, data: Dict[str, Any]) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Store flag data in cache.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
key: Cache key — typically the Redis flag key
|
|
84
|
+
data: Flag data dict from Redis HGETALL
|
|
85
|
+
"""
|
|
86
|
+
with self._lock:
|
|
87
|
+
self._store[key] = CacheEntry(data=data)
|
|
88
|
+
|
|
89
|
+
def delete(self, key: str) -> None:
|
|
90
|
+
"""
|
|
91
|
+
Remove a flag from cache.
|
|
92
|
+
Called when a flag is updated or deleted
|
|
93
|
+
so the next read fetches fresh data from Redis.
|
|
94
|
+
"""
|
|
95
|
+
with self._lock:
|
|
96
|
+
self._store.pop(key, None)
|
|
97
|
+
|
|
98
|
+
def clear(self) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Remove all entries from cache.
|
|
101
|
+
Useful for testing and for forcing a full refresh.
|
|
102
|
+
"""
|
|
103
|
+
with self._lock:
|
|
104
|
+
self._store.clear()
|
|
105
|
+
|
|
106
|
+
def size(self) -> int:
|
|
107
|
+
"""Number of entries currently in cache."""
|
|
108
|
+
with self._lock:
|
|
109
|
+
return len(self._store)
|
|
110
|
+
|
|
111
|
+
def preload(self, flags: Dict[str, Dict[str, Any]]) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Load multiple flags into cache at once.
|
|
114
|
+
Called on SDK startup to pre-warm the cache.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
flags: Dict of {cache_key: flag_data}
|
|
118
|
+
"""
|
|
119
|
+
with self._lock:
|
|
120
|
+
for key, data in flags.items():
|
|
121
|
+
self._store[key] = CacheEntry(data=data)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List, Optional, Any
|
|
4
|
+
|
|
5
|
+
import redis
|
|
6
|
+
|
|
7
|
+
from .cache import LocalCache
|
|
8
|
+
from .cohorts import CohortManager
|
|
9
|
+
from .evaluator import Evaluator
|
|
10
|
+
from .exceptions import FlagNotFoundError, InvalidRolloutError
|
|
11
|
+
from .schema import SchemaKeys
|
|
12
|
+
from .utils import now_unix
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FeatureFlags:
|
|
16
|
+
"""
|
|
17
|
+
Main entry point for redis-feature-flags.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
import redis
|
|
21
|
+
from redis_feature_flags import FeatureFlags
|
|
22
|
+
|
|
23
|
+
r = redis.Redis()
|
|
24
|
+
flags = FeatureFlags(r)
|
|
25
|
+
|
|
26
|
+
flags.create("dark_mode", rollout=10)
|
|
27
|
+
flags.is_enabled("dark_mode", user_id="alice")
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
SCHEMA_VERSION = "1"
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
redis_client: redis.Redis,
|
|
35
|
+
env: str = "prod",
|
|
36
|
+
cache_ttl: int = 30,
|
|
37
|
+
):
|
|
38
|
+
self._redis = redis_client
|
|
39
|
+
self._schema = SchemaKeys(env=env)
|
|
40
|
+
self._cache = LocalCache(ttl_seconds=cache_ttl)
|
|
41
|
+
self._evaluator = Evaluator(redis_client, self._schema, self._cache)
|
|
42
|
+
self._cohorts = CohortManager(redis_client, self._schema)
|
|
43
|
+
|
|
44
|
+
# ── Core evaluation ────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
def is_enabled(
|
|
47
|
+
self,
|
|
48
|
+
flag_name: str,
|
|
49
|
+
user_id: str,
|
|
50
|
+
default: bool = False,
|
|
51
|
+
) -> bool:
|
|
52
|
+
return self._evaluator.is_enabled(flag_name, user_id, default)
|
|
53
|
+
|
|
54
|
+
# ── Flag management ────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def create(
|
|
57
|
+
self,
|
|
58
|
+
flag_name: str,
|
|
59
|
+
rollout: int = 0,
|
|
60
|
+
created_by: str = "unknown",
|
|
61
|
+
) -> None:
|
|
62
|
+
if not 0 <= rollout <= 100:
|
|
63
|
+
raise InvalidRolloutError(rollout)
|
|
64
|
+
ts = str(now_unix())
|
|
65
|
+
self._redis.hset(
|
|
66
|
+
self._schema.flag(flag_name),
|
|
67
|
+
mapping={
|
|
68
|
+
"enabled": "0",
|
|
69
|
+
"rollout": str(rollout),
|
|
70
|
+
"expires_at": "0",
|
|
71
|
+
"created_at": ts,
|
|
72
|
+
"updated_at": ts,
|
|
73
|
+
"created_by": created_by,
|
|
74
|
+
"updated_by": created_by,
|
|
75
|
+
"flag_version": "1",
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
self._redis.sadd(self._schema.flags_index(), flag_name)
|
|
79
|
+
|
|
80
|
+
def enable(self, flag_name: str, updated_by: str = "unknown") -> None:
|
|
81
|
+
self._assert_exists(flag_name)
|
|
82
|
+
self._redis.hset(
|
|
83
|
+
self._schema.flag(flag_name),
|
|
84
|
+
mapping={
|
|
85
|
+
"enabled": "1",
|
|
86
|
+
"updated_at": str(now_unix()),
|
|
87
|
+
"updated_by": updated_by,
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
self._cache.delete(self._schema.flag(flag_name))
|
|
91
|
+
|
|
92
|
+
def disable(self, flag_name: str, updated_by: str = "unknown") -> None:
|
|
93
|
+
self._assert_exists(flag_name)
|
|
94
|
+
self._redis.hset(
|
|
95
|
+
self._schema.flag(flag_name),
|
|
96
|
+
mapping={
|
|
97
|
+
"enabled": "0",
|
|
98
|
+
"updated_at": str(now_unix()),
|
|
99
|
+
"updated_by": updated_by,
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
self._cache.delete(self._schema.flag(flag_name))
|
|
103
|
+
|
|
104
|
+
def set_rollout(
|
|
105
|
+
self,
|
|
106
|
+
flag_name: str,
|
|
107
|
+
percent: int,
|
|
108
|
+
updated_by: str = "unknown",
|
|
109
|
+
) -> None:
|
|
110
|
+
if not 0 <= percent <= 100:
|
|
111
|
+
raise InvalidRolloutError(percent)
|
|
112
|
+
self._assert_exists(flag_name)
|
|
113
|
+
self._redis.hset(
|
|
114
|
+
self._schema.flag(flag_name),
|
|
115
|
+
mapping={
|
|
116
|
+
"rollout": str(percent),
|
|
117
|
+
"updated_at": str(now_unix()),
|
|
118
|
+
"updated_by": updated_by,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
self._cache.delete(self._schema.flag(flag_name))
|
|
122
|
+
|
|
123
|
+
def delete(self, flag_name: str) -> None:
|
|
124
|
+
self._redis.delete(self._schema.flag(flag_name))
|
|
125
|
+
self._redis.delete(self._schema.flag_users(flag_name))
|
|
126
|
+
self._redis.delete(self._schema.flag_cohorts(flag_name))
|
|
127
|
+
self._redis.delete(self._schema.flag_history(flag_name))
|
|
128
|
+
self._redis.srem(self._schema.flags_index(), flag_name)
|
|
129
|
+
self._cache.delete(self._schema.flag(flag_name))
|
|
130
|
+
|
|
131
|
+
def list_flags(self) -> List[str]:
|
|
132
|
+
flags = self._redis.smembers(self._schema.flags_index())
|
|
133
|
+
return sorted([f.decode() for f in flags])
|
|
134
|
+
|
|
135
|
+
def get(self, flag_name: str) -> Dict[str, Any]:
|
|
136
|
+
self._assert_exists(flag_name)
|
|
137
|
+
data = self._redis.hgetall(self._schema.flag(flag_name))
|
|
138
|
+
return {k.decode(): v.decode() for k, v in data.items()}
|
|
139
|
+
|
|
140
|
+
# ── User targeting ─────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
def add_user(self, flag_name: str, user_id: str) -> None:
|
|
143
|
+
self._assert_exists(flag_name)
|
|
144
|
+
self._redis.sadd(self._schema.flag_users(flag_name), user_id)
|
|
145
|
+
|
|
146
|
+
def remove_user(self, flag_name: str, user_id: str) -> None:
|
|
147
|
+
self._redis.srem(self._schema.flag_users(flag_name), user_id)
|
|
148
|
+
|
|
149
|
+
# ── Cohort targeting ───────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
def create_cohort(self, cohort_name: str) -> None:
|
|
152
|
+
self._cohorts.create(cohort_name)
|
|
153
|
+
|
|
154
|
+
def add_to_cohort(self, cohort_name: str, user_id: str) -> None:
|
|
155
|
+
self._cohorts.add_user(cohort_name, user_id)
|
|
156
|
+
|
|
157
|
+
def remove_from_cohort(self, cohort_name: str, user_id: str) -> None:
|
|
158
|
+
self._cohorts.remove_user(cohort_name, user_id)
|
|
159
|
+
|
|
160
|
+
def add_cohort_to_flag(self, flag_name: str, cohort_name: str) -> None:
|
|
161
|
+
self._assert_exists(flag_name)
|
|
162
|
+
self._redis.sadd(self._schema.flag_cohorts(flag_name), cohort_name)
|
|
163
|
+
|
|
164
|
+
def remove_cohort_from_flag(
|
|
165
|
+
self, flag_name: str, cohort_name: str
|
|
166
|
+
) -> None:
|
|
167
|
+
self._redis.srem(self._schema.flag_cohorts(flag_name), cohort_name)
|
|
168
|
+
|
|
169
|
+
# ── Private helpers ────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
def _assert_exists(self, flag_name: str) -> None:
|
|
172
|
+
if not self._redis.exists(self._schema.flag(flag_name)):
|
|
173
|
+
raise FlagNotFoundError(flag_name)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import redis
|
|
4
|
+
|
|
5
|
+
from .exceptions import CohortNotFoundError
|
|
6
|
+
from .schema import SchemaKeys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CohortManager:
|
|
10
|
+
"""
|
|
11
|
+
Manages cohorts in Redis.
|
|
12
|
+
|
|
13
|
+
A cohort is a named group of users — e.g. beta-testers, premium-users.
|
|
14
|
+
Flags can target entire cohorts instead of individual users.
|
|
15
|
+
|
|
16
|
+
Uses a bidirectional index:
|
|
17
|
+
Direction 1 — ff:{env}:cohort:{name} → Set of user_ids
|
|
18
|
+
Direction 2 — ff:{env}:user:{id}:cohorts → Set of cohort names
|
|
19
|
+
|
|
20
|
+
Direction 1 answers: "who is in this cohort?" (management)
|
|
21
|
+
Direction 2 answers: "which cohorts does this user belong to?" (evaluation)
|
|
22
|
+
Both directions must always stay in sync.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
redis_client: redis.Redis,
|
|
28
|
+
schema: SchemaKeys,
|
|
29
|
+
):
|
|
30
|
+
self._redis = redis_client
|
|
31
|
+
self._schema = schema
|
|
32
|
+
|
|
33
|
+
def create(self, cohort_name: str) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Register a cohort name in the cohorts index.
|
|
36
|
+
|
|
37
|
+
Adds cohort_name to ff:{env}:cohorts:__index__ so it
|
|
38
|
+
appears in list_cohorts() without needing a KEYS * scan.
|
|
39
|
+
Does not add any members — cohort starts empty.
|
|
40
|
+
"""
|
|
41
|
+
self._redis.sadd(self._schema.cohorts_index(), cohort_name)
|
|
42
|
+
|
|
43
|
+
def delete(self, cohort_name: str) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Fully remove a cohort from Redis — cleans up all three locations.
|
|
46
|
+
|
|
47
|
+
Step 1 — get all members before deleting
|
|
48
|
+
so we can clean their reverse index keys.
|
|
49
|
+
|
|
50
|
+
Step 2 — pipeline all removals atomically:
|
|
51
|
+
a. Remove cohort name from every member's reverse index
|
|
52
|
+
ff:{env}:user:{user_id}:cohorts
|
|
53
|
+
b. Delete the cohort members Set
|
|
54
|
+
ff:{env}:cohort:{name}
|
|
55
|
+
c. Remove cohort name from the cohorts index
|
|
56
|
+
ff:{env}:cohorts:__index__
|
|
57
|
+
|
|
58
|
+
Note: does NOT clean up flag cohort Sets that reference this cohort.
|
|
59
|
+
ff:{env}:flag:{name}:cohorts may still contain this cohort name.
|
|
60
|
+
Evaluation is unaffected — SINTER finds no match once the cohort
|
|
61
|
+
is gone from the user reverse index.
|
|
62
|
+
Full flag cleanup is planned for v2.
|
|
63
|
+
"""
|
|
64
|
+
members = self._redis.smembers(self._schema.cohort(cohort_name))
|
|
65
|
+
|
|
66
|
+
pipe = self._redis.pipeline()
|
|
67
|
+
for member in members:
|
|
68
|
+
pipe.srem(self._schema.user_cohorts(member.decode()), cohort_name)
|
|
69
|
+
pipe.delete(self._schema.cohort(cohort_name))
|
|
70
|
+
pipe.srem(self._schema.cohorts_index(), cohort_name)
|
|
71
|
+
pipe.execute()
|
|
72
|
+
|
|
73
|
+
def add_user(self, cohort_name: str, user_id: str) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Add a user to a cohort — writes both directions atomically.
|
|
76
|
+
|
|
77
|
+
Uses a Redis pipeline to send both commands in one round trip
|
|
78
|
+
and execute them together — either both succeed or both fail.
|
|
79
|
+
|
|
80
|
+
Line 1 — direction 1 (cohort → members):
|
|
81
|
+
SADD ff:{env}:cohort:{name} {user_id}
|
|
82
|
+
|
|
83
|
+
Line 2 — direction 2 (user → cohorts) reverse index:
|
|
84
|
+
SADD ff:{env}:user:{user_id}:cohorts {cohort_name}
|
|
85
|
+
|
|
86
|
+
Direction 2 is what makes evaluation fast — SINTER can find
|
|
87
|
+
cohort matches in one Redis call instead of checking each cohort.
|
|
88
|
+
"""
|
|
89
|
+
pipe = self._redis.pipeline()
|
|
90
|
+
pipe.sadd(self._schema.cohort(cohort_name), user_id)
|
|
91
|
+
pipe.sadd(self._schema.user_cohorts(user_id), cohort_name)
|
|
92
|
+
pipe.execute()
|
|
93
|
+
|
|
94
|
+
def remove_user(self, cohort_name: str, user_id: str) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Remove a user from a cohort — removes both directions atomically.
|
|
97
|
+
|
|
98
|
+
Uses a Redis pipeline — both removals happen together.
|
|
99
|
+
|
|
100
|
+
Line 1 — removes user from cohort members Set:
|
|
101
|
+
SREM ff:{env}:cohort:{name} {user_id}
|
|
102
|
+
|
|
103
|
+
Line 2 — removes cohort from user's reverse index:
|
|
104
|
+
SREM ff:{env}:user:{user_id}:cohorts {cohort_name}
|
|
105
|
+
|
|
106
|
+
Both directions must be removed to keep the index consistent.
|
|
107
|
+
"""
|
|
108
|
+
pipe = self._redis.pipeline()
|
|
109
|
+
pipe.srem(self._schema.cohort(cohort_name), user_id)
|
|
110
|
+
pipe.srem(self._schema.user_cohorts(user_id), cohort_name)
|
|
111
|
+
pipe.execute()
|
|
112
|
+
|
|
113
|
+
def get_members(self, cohort_name: str) -> set:
|
|
114
|
+
"""
|
|
115
|
+
Return all user_ids in a cohort as a Python set of strings.
|
|
116
|
+
|
|
117
|
+
Reads from direction 1 — ff:{env}:cohort:{name}.
|
|
118
|
+
Redis returns bytes — decoded to strings before returning.
|
|
119
|
+
Returns empty set if cohort does not exist.
|
|
120
|
+
|
|
121
|
+
Used for management — e.g. CLI command "show me who is in beta-testers".
|
|
122
|
+
Not used during flag evaluation — direction 2 is used there instead.
|
|
123
|
+
"""
|
|
124
|
+
members = self._redis.smembers(self._schema.cohort(cohort_name))
|
|
125
|
+
return {m.decode() for m in members}
|
|
126
|
+
|
|
127
|
+
def list_cohorts(self) -> list:
|
|
128
|
+
"""
|
|
129
|
+
Return all cohort names as a Python list of strings.
|
|
130
|
+
|
|
131
|
+
Reads from ff:{env}:cohorts:__index__ — the cohort name registry.
|
|
132
|
+
Redis returns bytes — decoded to strings before returning.
|
|
133
|
+
|
|
134
|
+
Safe to call in production — reads one Set key, no KEYS * scan.
|
|
135
|
+
"""
|
|
136
|
+
cohorts = self._redis.smembers(self._schema.cohorts_index())
|
|
137
|
+
return [c.decode() for c in cohorts]
|
|
138
|
+
|
|
139
|
+
def exists(self, cohort_name: str) -> bool:
|
|
140
|
+
"""
|
|
141
|
+
Check if a cohort name is registered in the index.
|
|
142
|
+
|
|
143
|
+
Uses SISMEMBER on ff:{env}:cohorts:__index__ — O(1) lookup.
|
|
144
|
+
Returns True if cohort exists, False otherwise.
|
|
145
|
+
|
|
146
|
+
Note: a cohort can exist in the index but have zero members.
|
|
147
|
+
exists() only checks registration — not whether it has members.
|
|
148
|
+
"""
|
|
149
|
+
return bool(
|
|
150
|
+
self._redis.sismember(
|
|
151
|
+
self._schema.cohorts_index(), cohort_name
|
|
152
|
+
)
|
|
153
|
+
)
|