synapse-memory-engine 1.3.0__py3-none-any.whl

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.
@@ -0,0 +1,68 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import List, Dict, Any
3
+ import time
4
+ import logging
5
+ from synapse_memory.core.decay_engine import DecayEngine
6
+
7
+ logger = logging.getLogger("Pruner")
8
+
9
+
10
+ class PruningPolicy(ABC):
11
+ """Abstract base class for memory pruning policies."""
12
+
13
+ @abstractmethod
14
+ def identify_targets(self, memories: List[Dict[str, Any]]) -> List[str]:
15
+ """Returns list of memory IDs to be pruned based on the policy."""
16
+ pass
17
+
18
+
19
+ class TTLPruningPolicy(PruningPolicy):
20
+ """Prunes memories older than a specified duration in seconds."""
21
+ def __init__(self, ttl_seconds: float):
22
+ self.ttl_seconds = ttl_seconds
23
+
24
+ def identify_targets(self, memories: List[Dict[str, Any]]) -> List[str]:
25
+ now = time.time()
26
+ return [m["id"] for m in memories if now - m["created_at"] > self.ttl_seconds]
27
+
28
+
29
+ class ConfidencePruningPolicy(PruningPolicy):
30
+ """Prunes memories below a certain confidence threshold."""
31
+ def __init__(self, confidence_threshold: float):
32
+ self.confidence_threshold = confidence_threshold
33
+
34
+ def identify_targets(self, memories: List[Dict[str, Any]]) -> List[str]:
35
+ return [m["id"] for m in memories if m.get("confidence", 0.0) < self.confidence_threshold]
36
+
37
+
38
+ class CapacityPruningPolicy(PruningPolicy):
39
+ """Prunes lowest-relevance memories to maintain capacity."""
40
+ def __init__(self, max_records: int, decay_engine: DecayEngine):
41
+ self.max_records = max_records
42
+ self.decay_engine = decay_engine
43
+
44
+ def identify_targets(self, memories: List[Dict[str, Any]]) -> List[str]:
45
+ if len(memories) <= self.max_records:
46
+ return []
47
+
48
+ # Calculate relevance using robust DecayEngine
49
+ scored = []
50
+ for m in memories:
51
+ relevance = self.decay_engine.calculate_relevance(m)
52
+ scored.append((relevance, m["id"]))
53
+
54
+ scored.sort(key=lambda x: x[0])
55
+ excess = len(memories) - self.max_records
56
+ return [m_id for _, m_id in scored[:excess]]
57
+
58
+
59
+ class PruningEngine:
60
+ """Engine to orchestrate multiple pruning policies."""
61
+ def __init__(self, policies: List[PruningPolicy]):
62
+ self.policies = policies
63
+
64
+ def run_pruning(self, memories: List[Dict[str, Any]]) -> List[str]:
65
+ targets = set()
66
+ for policy in self.policies:
67
+ targets.update(policy.identify_targets(memories))
68
+ return list(targets)
@@ -0,0 +1,180 @@
1
+ """Distributed-safe request rate limiting.
2
+
3
+ Two interchangeable implementations behind one tiny interface (``check``):
4
+
5
+ * :class:`InProcessRateLimiter` — a thread-safe sliding window with **no
6
+ external dependencies**. Correct for a single process / single replica, and
7
+ used as the automatic fallback everywhere else.
8
+ * :class:`RedisRateLimiter` — a Redis sorted-set sliding window executed as an
9
+ atomic Lua script, so the limit is enforced across **all** engine replicas
10
+ sharing one Redis. This is what multi-replica HA deployments need.
11
+
12
+ :func:`build_rate_limiter` picks Redis when ``REDIS_URL`` is configured *and* the
13
+ optional ``redis`` client is importable and reachable; otherwise it degrades to
14
+ the in-process window and logs why.
15
+
16
+ Design notes (production):
17
+ * Redis is treated as a *hard dependency only when configured*: if it is
18
+ unreachable at call time we fall back to the in-process window instead of
19
+ failing every request. A Redis outage therefore degrades protection from
20
+ cluster-wide to per-replica — it never turns into a full outage.
21
+ * Keys carry the caller identity + path and expire automatically
22
+ (``PEXPIRE``), so an unbounded key space is not possible.
23
+ * The in-process limiter prunes idle buckets so memory cannot grow without
24
+ bound under a high-cardinality key space.
25
+ """
26
+
27
+ import logging
28
+ import os
29
+ import threading
30
+ import time
31
+ import uuid
32
+ from typing import Any, Dict, List, Optional
33
+
34
+ logger = logging.getLogger("SynapseRateLimit")
35
+
36
+ # Atomic sliding-window check:
37
+ # KEYS[1] = bucket key
38
+ # ARGV[1] = now (epoch seconds) ARGV[2] = window (seconds)
39
+ # ARGV[3] = limit ARGV[4] = unique member id
40
+ # Returns 1 if the request is allowed, 0 if the limit is exceeded.
41
+ _SLIDING_WINDOW_LUA = """
42
+ local key = KEYS[1]
43
+ local now = tonumber(ARGV[1])
44
+ local window = tonumber(ARGV[2])
45
+ local limit = tonumber(ARGV[3])
46
+ local member = ARGV[4]
47
+ redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)
48
+ local count = redis.call('ZCARD', key)
49
+ if count >= limit then
50
+ return 0
51
+ end
52
+ redis.call('ZADD', key, now, member)
53
+ redis.call('PEXPIRE', key, math.ceil(window * 1000))
54
+ return 1
55
+ """
56
+
57
+
58
+ class InProcessRateLimiter:
59
+ """Thread-safe sliding-window limiter, scoped to this process only."""
60
+
61
+ backend = "in-process"
62
+
63
+ def __init__(self) -> None:
64
+ self._buckets: Dict[str, List[float]] = {}
65
+ self._lock = threading.Lock()
66
+
67
+ def check(self, key: str, limit: int, window_s: float = 60.0) -> bool:
68
+ """Return True if the request is allowed, False if the limit is exceeded."""
69
+ now = time.time()
70
+ window_start = now - window_s
71
+ with self._lock:
72
+ bucket = self._buckets.get(key)
73
+ if bucket is None:
74
+ bucket = []
75
+ self._buckets[key] = bucket
76
+ else:
77
+ # Drop timestamps that have fallen out of the window.
78
+ bucket[:] = [t for t in bucket if t > window_start]
79
+ if len(bucket) >= limit:
80
+ # A non-positive limit can never recover; evict the bucket so a
81
+ # hostile key space cannot grow without bound.
82
+ if limit <= 0:
83
+ self._buckets.pop(key, None)
84
+ return False
85
+ bucket.append(now)
86
+ return True
87
+
88
+ def prune_idle(self, max_idle_seconds: float = 300.0) -> int:
89
+ """Remove buckets whose newest timestamp is older than max_idle_seconds.
90
+
91
+ Bounds memory growth under a high-cardinality key space (e.g. spoofed
92
+ API keys). Call periodically from a background thread or request path.
93
+ Returns the number of buckets removed.
94
+ """
95
+ cutoff = time.time() - max_idle_seconds
96
+ removed = 0
97
+ with self._lock:
98
+ stale = [
99
+ key for key, bucket in self._buckets.items()
100
+ if not bucket or bucket[-1] < cutoff
101
+ ]
102
+ for key in stale:
103
+ self._buckets.pop(key, None)
104
+ removed += 1
105
+ return removed
106
+
107
+ def reset(self) -> None:
108
+ """Clear all windows (used by tests and on config reload)."""
109
+ with self._lock:
110
+ self._buckets.clear()
111
+
112
+
113
+ class RedisRateLimiter:
114
+ """Redis sorted-set sliding-window limiter shared across replicas."""
115
+
116
+ backend = "redis"
117
+
118
+ def __init__(
119
+ self,
120
+ client: Any,
121
+ prefix: str = "synapse:rl:",
122
+ fallback: Optional[InProcessRateLimiter] = None,
123
+ ) -> None:
124
+ self._client = client
125
+ self._prefix = prefix
126
+ # Local safety net used when Redis is temporarily unreachable.
127
+ self._fallback = fallback if fallback is not None else InProcessRateLimiter()
128
+ try:
129
+ self._script = client.register_script(_SLIDING_WINDOW_LUA)
130
+ except Exception as exc: # pragma: no cover - client-specific
131
+ logger.warning("Redis EVAL unavailable (%s); falling back per-call.", exc)
132
+ self._script = None
133
+
134
+ def check(self, key: str, limit: int, window_s: float = 60.0) -> bool:
135
+ if self._script is None:
136
+ return self._fallback.check(key, limit, window_s)
137
+ redis_key = f"{self._prefix}{key}"
138
+ member = f"{time.time()}-{uuid.uuid4().hex}"
139
+ try:
140
+ allowed = self._script(
141
+ keys=[redis_key],
142
+ args=[time.time(), window_s, limit, member],
143
+ )
144
+ return bool(int(allowed))
145
+ except Exception as exc:
146
+ logger.warning("Redis rate-limit check failed (%s); using in-process fallback.", exc)
147
+ return self._fallback.check(key, limit, window_s)
148
+
149
+
150
+ def build_rate_limiter(redis_url: Optional[str] = None) -> Any:
151
+ """Construct the best available limiter.
152
+
153
+ Prefers a Redis-backed limiter when ``REDIS_URL`` (or the explicit argument)
154
+ is set and Redis is reachable; otherwise returns an
155
+ :class:`InProcessRateLimiter`. Never raises.
156
+ """
157
+ url = redis_url if redis_url is not None else os.environ.get("REDIS_URL", "")
158
+ if not url:
159
+ logger.info("REDIS_URL not set; using in-process rate limiter (single-replica only).")
160
+ return InProcessRateLimiter()
161
+ try:
162
+ import redis # type: ignore
163
+ except Exception:
164
+ logger.warning(
165
+ "REDIS_URL set but the 'redis' package is not installed; using in-process rate limiter."
166
+ )
167
+ return InProcessRateLimiter()
168
+ try:
169
+ client = redis.Redis.from_url(
170
+ url,
171
+ socket_connect_timeout=2.0,
172
+ socket_timeout=2.0,
173
+ decode_responses=False,
174
+ )
175
+ client.ping()
176
+ logger.info("Distributed rate limiting enabled via Redis.")
177
+ return RedisRateLimiter(client)
178
+ except Exception as exc:
179
+ logger.warning("Redis unreachable (%s); using in-process rate limiter.", exc)
180
+ return InProcessRateLimiter()