Skip to content

Commit e7f7066

Browse files
authored
fix(circuit_breaker): make probe election per-thread and synchronize local counters (#8323)
* fix(circuit_breaker): make probe election per-thread and synchronize local counters The half-open probe owner id was minted once per process, so every thread in a process passed the owner check once any sibling won the election and all of them probed the recovering backend. The id is now a per-thread uuid (pid-aware for forked workers); the existing DynamoDB conditional write then elects one prober across threads and processes with no protocol change. The in-memory failure/success counters and observed-state map are now guarded by a lock, with threshold crossings detected atomically so a trip is persisted exactly once. Persistence settings are keyed per circuit name instead of living as shared instance attributes, and the local cache no longer raises into the protected call on concurrent expiry. * test(circuit_breaker): cover losing the expired-lease probe takeover election The takeover's failure arm was untested: when another environment wins the conditional election, the caller must get the open-circuit response and the protected function must not run. * fix(circuit_breaker): address review feedback - Make the counter race test catch a missing lock: park readers mid-increment so an unsynchronized read-modify-write deterministically loses updates (fails 10/10 unlocked, passes 10/10 locked) - Document that on_circuit_open and on_transition callbacks can run concurrently and must be thread-safe - Use dict.get's default instead of `or` for the per-circuit settings lookup * fix(circuit_breaker): avoid LRUDict.pop when evicting expired cache entries - On Python 3.10, OrderedDict.pop re-enters the subclass __getitem__ after detaching the linked-list node, so LRUDict.pop raises KeyError for a present key and corrupts the dict (fixed in CPython 3.11) — this failed 14 tests on the 3.10 CI job only - Evict with a guarded del instead, which never calls subclass hooks; the surrounding lock already makes the get-then-delete atomic, and the try/except keeps the never-raise-into-the-protected-call contract explicit
1 parent bc78b7f commit e7f7066

7 files changed

Lines changed: 433 additions & 105 deletions

File tree

aws_lambda_powertools/utilities/circuit_breaker_alpha/base.py

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
import datetime
1212
import logging
13+
import os
14+
import threading
1315
import uuid
1416
from typing import TYPE_CHECKING, Any
1517

@@ -37,8 +39,31 @@
3739
# recovered), so stale local failure streaks can be invalidated.
3840
_LAST_OBSERVED_STATE: dict[str, CircuitState] = {}
3941

40-
# Stable per-environment identifier used to claim the half-open probe lock.
41-
_ENVIRONMENT_ID = uuid.uuid4().hex
42+
# Guards the three dicts above. Increments are read-modify-write and a threshold
43+
# crossing must be observed by exactly one thread, so every access goes through this
44+
# lock. Held only while mutating the dicts, never across persistence writes or user
45+
# callbacks.
46+
_COUNTERS_LOCK = threading.Lock()
47+
48+
# Identifier used to claim the half-open probe lock, unique per thread so the store's
49+
# conditional election picks a single prober across threads as well as processes.
50+
_PROBE_OWNER = threading.local()
51+
52+
53+
def _probe_owner_id() -> str:
54+
"""
55+
Return this thread's stable probe-owner identifier, minting it on first use.
56+
57+
A uuid in thread-local storage rather than ``threading.get_ident()``: the OS reuses
58+
thread ids, and a recycled id would let an unrelated thread pass the owner check and
59+
probe alongside the real owner. The pid check re-mints the id in forked children,
60+
which inherit the forking thread's local storage.
61+
"""
62+
pid = os.getpid()
63+
if getattr(_PROBE_OWNER, "pid", None) != pid:
64+
_PROBE_OWNER.id = f"{pid}#{uuid.uuid4().hex}"
65+
_PROBE_OWNER.pid = pid
66+
return _PROBE_OWNER.id
4267

4368

4469
class CircuitBreakerHandler:
@@ -111,32 +136,35 @@ def handle(self) -> Any:
111136
# If we previously observed a non-CLOSED state and the circuit is now back to
112137
# CLOSED, another environment completed the recovery cycle. Reset local counters
113138
# so a stale partial failure streak doesn't immediately re-trip the circuit.
114-
prev = _LAST_OBSERVED_STATE.get(self.name)
115-
if prev is not None and prev != CircuitState.CLOSED:
116-
_LOCAL_FAILURES[self.name] = 0
117-
_LAST_OBSERVED_STATE[self.name] = CircuitState.CLOSED
139+
with _COUNTERS_LOCK:
140+
prev = _LAST_OBSERVED_STATE.get(self.name)
141+
if prev is not None and prev != CircuitState.CLOSED:
142+
_LOCAL_FAILURES[self.name] = 0
143+
_LAST_OBSERVED_STATE[self.name] = CircuitState.CLOSED
118144
return self._call_closed()
119145

120146
if record.state == CircuitState.OPEN:
121-
_LAST_OBSERVED_STATE[self.name] = CircuitState.OPEN
147+
with _COUNTERS_LOCK:
148+
_LAST_OBSERVED_STATE[self.name] = CircuitState.OPEN
122149
# ``opened_at`` may legitimately be 0 (epoch); treat only None as missing.
123150
opened_at = record.opened_at if record.opened_at is not None else self._now()
124151
if self._now() >= opened_at + self.config.recovery_timeout:
125152
# Recovery window elapsed: try to become the single prober.
126-
if self.persistence_store.try_acquire_half_open(self.name, _ENVIRONMENT_ID, opened_at):
153+
if self.persistence_store.try_acquire_half_open(self.name, _probe_owner_id(), opened_at):
127154
self._notify(CircuitState.OPEN, CircuitState.HALF_OPEN, opened_at=opened_at)
128155
return self._call_probe()
129156
return self._open_response(record.to_circuit_info())
130157

131-
# HALF_OPEN: only the environment that owns the probe lock runs.
132-
_LAST_OBSERVED_STATE[self.name] = CircuitState.HALF_OPEN
133-
if record.half_open_owner == _ENVIRONMENT_ID:
158+
# HALF_OPEN: only the thread that owns the probe lock runs.
159+
with _COUNTERS_LOCK:
160+
_LAST_OBSERVED_STATE[self.name] = CircuitState.HALF_OPEN
161+
if record.half_open_owner == _probe_owner_id():
134162
return self._call_probe()
135163

136164
# If the probe lease has expired (owner recycled mid-probe), take over.
137165
if record.probe_lease_expiry is not None and self._now() >= record.probe_lease_expiry:
138166
logger.debug("Circuit '%s' probe lease expired; attempting takeover.", self.name)
139-
if self.persistence_store.try_acquire_half_open(self.name, _ENVIRONMENT_ID, record.opened_at or 0):
167+
if self.persistence_store.try_acquire_half_open(self.name, _probe_owner_id(), record.opened_at or 0):
140168
return self._call_probe()
141169

142170
return self._open_response(record.to_circuit_info())
@@ -148,9 +176,14 @@ def _call_closed(self) -> Any:
148176
except Exception as exc:
149177
if not self.config.counts_as_failure(exc):
150178
raise
151-
failures = _LOCAL_FAILURES.get(self.name, 0) + 1
152-
_LOCAL_FAILURES[self.name] = failures
153-
if failures >= self.config.failure_threshold:
179+
# Increment and reset atomically so exactly one thread observes the threshold
180+
# crossing; racing threads would otherwise lose increments (tripping late) or
181+
# each persist the same transition.
182+
with _COUNTERS_LOCK:
183+
failures = _LOCAL_FAILURES.get(self.name, 0) + 1
184+
tripped = failures >= self.config.failure_threshold
185+
_LOCAL_FAILURES[self.name] = 0 if tripped else failures
186+
if tripped:
154187
logger.debug("Circuit '%s' tripping CLOSED to OPEN after %d failures.", self.name, failures)
155188
opened_at = self._now()
156189
self._safe_persist(
@@ -159,11 +192,11 @@ def _call_closed(self) -> Any:
159192
failure_count=failures,
160193
opened_at=opened_at,
161194
)
162-
_LOCAL_FAILURES[self.name] = 0
163195
self._notify(CircuitState.CLOSED, CircuitState.OPEN, opened_at=opened_at)
164196
raise
165197
else:
166-
_LOCAL_FAILURES[self.name] = 0
198+
with _COUNTERS_LOCK:
199+
_LOCAL_FAILURES[self.name] = 0
167200
return result
168201

169202
def _call_probe(self) -> Any:
@@ -176,17 +209,20 @@ def _call_probe(self) -> Any:
176209
logger.debug("Circuit '%s' probe failed; reopening.", self.name)
177210
opened_at = self._now()
178211
self._safe_persist(self.persistence_store.save_reopen, self.name, opened_at=opened_at)
179-
_LOCAL_SUCCESSES[self.name] = 0
212+
with _COUNTERS_LOCK:
213+
_LOCAL_SUCCESSES[self.name] = 0
180214
self._notify(CircuitState.HALF_OPEN, CircuitState.OPEN, opened_at=opened_at)
181215
raise
182216
else:
183-
successes = _LOCAL_SUCCESSES.get(self.name, 0) + 1
184-
_LOCAL_SUCCESSES[self.name] = successes
185-
if successes >= self.config.success_threshold:
217+
with _COUNTERS_LOCK:
218+
successes = _LOCAL_SUCCESSES.get(self.name, 0) + 1
219+
closed = successes >= self.config.success_threshold
220+
_LOCAL_SUCCESSES[self.name] = 0 if closed else successes
221+
if closed:
222+
_LOCAL_FAILURES[self.name] = 0
223+
if closed:
186224
logger.debug("Circuit '%s' closing after %d probe successes.", self.name, successes)
187225
self._safe_persist(self.persistence_store.save_closed, self.name)
188-
_LOCAL_SUCCESSES[self.name] = 0
189-
_LOCAL_FAILURES[self.name] = 0
190226
self._notify(CircuitState.HALF_OPEN, CircuitState.CLOSED)
191227
return result
192228

aws_lambda_powertools/utilities/circuit_breaker_alpha/persistence/base.py

Lines changed: 70 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010

1111
import datetime
1212
import logging
13+
import threading
1314
from abc import ABC, abstractmethod
14-
from typing import TYPE_CHECKING
15+
from typing import TYPE_CHECKING, NamedTuple
1516

1617
from aws_lambda_powertools.shared.cache_dict import LRUDict
1718
from aws_lambda_powertools.utilities.circuit_breaker_alpha.persistence.record import CircuitStateRecord
@@ -32,6 +33,17 @@
3233
PERSISTED_STATE_TTL_BUFFER = 3600
3334

3435

36+
class _CircuitSettings(NamedTuple):
37+
"""Per-circuit tunables the layer captures from :meth:`configure`."""
38+
39+
local_cache_max_age: int
40+
recovery_timeout: int
41+
42+
43+
# Fallback for direct layer use before configure() has run; mirrors CircuitBreakerConfig defaults.
44+
_DEFAULT_SETTINGS = _CircuitSettings(local_cache_max_age=5, recovery_timeout=30)
45+
46+
3547
class CircuitBreakerExistingLockError(Exception):
3648
"""Internal signal that a conditional half-open probe write lost the race."""
3749

@@ -50,63 +62,86 @@ class CircuitBreakerPersistenceLayer(ABC):
5062

5163
def __init__(self) -> None:
5264
"""Initialize defaults; real configuration happens in :meth:`configure`."""
53-
self.circuit_name: str = ""
54-
self.local_cache_max_age: int = 5
55-
self.recovery_timeout: int = 30
65+
# Per-circuit tunables, keyed by circuit name. One persistence instance is shared
66+
# by every circuit (and thread) using the same store, so these must never live in
67+
# plain instance attributes: circuits with different configs would stamp each
68+
# other's TTL and probe lease. A plain dict, not an LRUDict: evicting a live
69+
# circuit's settings would silently swap in the defaults (wrong lease and TTL),
70+
# whereas evicting a cache entry below only costs a store re-read.
71+
self._settings: dict[str, _CircuitSettings] = {}
5672
# Maps circuit name -> the unix timestamp the locally cached record goes stale.
5773
# Kept separate from the record's durable ``expiry_timestamp`` (the store TTL) so
5874
# the short in-memory freshness window is never mistaken for the long store TTL.
5975
self._cache: LRUDict = LRUDict(max_items=LOCAL_CACHE_MAX_ITEMS)
76+
# One lock for both maps: LRUDict reorders entries even on reads, so unguarded
77+
# concurrent access can corrupt it or raise.
78+
self._lock = threading.Lock()
6079

6180
def configure(self, config: CircuitBreakerConfig, circuit_name: str) -> None:
6281
"""
63-
Bind the layer to a circuit and its configuration.
82+
Bind a circuit's configuration to the layer.
6483
65-
Called once per invocation by the handler; the assignments are cheap and the
84+
Called once per invocation by the handler; the assignment is cheap and the
6685
same persistence instance is reused across invocations within an environment.
6786
6887
Parameters
6988
----------
7089
config : CircuitBreakerConfig
7190
Configuration providing the local cache TTL and recovery timeout.
7291
circuit_name : str
73-
The circuit this layer instance serves.
92+
The circuit these settings apply to.
7493
"""
75-
self.circuit_name = circuit_name
76-
self.local_cache_max_age = config.local_cache_max_age
77-
self.recovery_timeout = config.recovery_timeout
94+
with self._lock:
95+
self._settings[circuit_name] = _CircuitSettings(
96+
local_cache_max_age=config.local_cache_max_age,
97+
recovery_timeout=config.recovery_timeout,
98+
)
99+
100+
def _settings_for(self, name: str) -> _CircuitSettings:
101+
"""Return a circuit's configured settings, or the defaults if never configured."""
102+
with self._lock:
103+
return self._settings.get(name, _DEFAULT_SETTINGS)
78104

79105
# ------------------------------------------------------------------ cache
80106

81107
def _cache_key(self, name: str) -> str:
82108
return name
83109

84-
def _durable_ttl(self) -> int:
110+
def _durable_ttl(self, name: str) -> int:
85111
"""
86112
Compute the store TTL stamped on a persisted record.
87113
88114
Sized to outlive a full recovery window so a live circuit is never reaped
89115
mid-cycle, while an abandoned circuit (no further writes) self-cleans soon after.
90116
"""
91-
return int(datetime.datetime.now().timestamp()) + self.recovery_timeout + PERSISTED_STATE_TTL_BUFFER
117+
now = int(datetime.datetime.now().timestamp())
118+
return now + self._settings_for(name).recovery_timeout + PERSISTED_STATE_TTL_BUFFER
92119

93120
def _save_to_cache(self, record: CircuitStateRecord) -> None:
94121
"""Cache a record locally with a short in-memory freshness window."""
95-
local_expiry = int(datetime.datetime.now().timestamp()) + self.local_cache_max_age
96-
self._cache[self._cache_key(record.name)] = (local_expiry, record)
122+
local_expiry = int(datetime.datetime.now().timestamp()) + self._settings_for(record.name).local_cache_max_age
123+
with self._lock:
124+
self._cache[self._cache_key(record.name)] = (local_expiry, record)
97125

98126
def _retrieve_from_cache(self, name: str) -> CircuitStateRecord | None:
99127
"""Return a cached record if present and still within its local freshness window."""
100-
cached = self._cache.get(self._cache_key(name))
101-
if cached is None:
102-
return None
103-
104-
local_expiry, record = cached
105-
if int(datetime.datetime.now().timestamp()) >= local_expiry:
106-
del self._cache[self._cache_key(name)]
107-
return None
108-
109-
return record
128+
with self._lock:
129+
cached = self._cache.get(self._cache_key(name))
130+
if cached is None:
131+
return None
132+
133+
local_expiry, record = cached
134+
if int(datetime.datetime.now().timestamp()) >= local_expiry:
135+
# Guarded del, not pop: on Python 3.10 OrderedDict.pop re-enters the
136+
# subclass __getitem__ after detaching the node, so LRUDict.pop raises
137+
# KeyError for a *present* key and corrupts the dict (fixed in 3.11).
138+
try:
139+
del self._cache[self._cache_key(name)]
140+
except KeyError:
141+
pass
142+
return None
143+
144+
return record
110145

111146
# ------------------------------------------------------------- public API
112147

@@ -175,45 +210,46 @@ def save_open(self, name: str, failure_count: int, opened_at: int) -> None:
175210
state=CircuitState.OPEN,
176211
failure_count=failure_count,
177212
opened_at=opened_at,
178-
expiry_timestamp=self._durable_ttl(),
213+
expiry_timestamp=self._durable_ttl(name),
179214
)
180215
self._put_record(record)
181216
self._save_to_cache(record)
182217

183218
def try_acquire_half_open(self, name: str, owner: str, opened_at: int) -> bool:
184219
"""
185-
Atomically elect a single environment to run the half-open probe.
220+
Atomically elect a single worker to run the half-open probe.
186221
187222
The conditional write succeeds only when the circuit is OPEN with no existing
188223
lock owner AND the ``opened_at`` matches what the caller observed (guards against
189224
stale eventually-consistent reads). A lease expiry is stamped so that if the
190-
winning environment is recycled before completing the probe, others can take over
225+
winning worker is recycled before completing the probe, others can take over
191226
once the lease lapses.
192227
193228
Parameters
194229
----------
195230
name : str
196231
Circuit name.
197232
owner : str
198-
Identifier of the environment attempting the probe.
233+
Identifier of the worker (one thread in one execution environment)
234+
attempting the probe.
199235
opened_at : int
200236
The ``opened_at`` the caller observed, kept stable across the transition.
201237
202238
Returns
203239
-------
204240
bool
205-
``True`` if this environment won the probe lock, ``False`` if another
206-
environment already holds it.
241+
``True`` if this worker won the probe lock, ``False`` if another
242+
worker already holds it.
207243
"""
208244
# Lease = recovery_timeout gives the probe a full cycle to complete.
209-
probe_lease_expiry = int(datetime.datetime.now().timestamp()) + self.recovery_timeout
245+
probe_lease_expiry = int(datetime.datetime.now().timestamp()) + self._settings_for(name).recovery_timeout
210246
record = CircuitStateRecord(
211247
name=name,
212248
state=CircuitState.HALF_OPEN,
213249
opened_at=opened_at,
214250
half_open_owner=owner,
215251
probe_lease_expiry=probe_lease_expiry,
216-
expiry_timestamp=self._durable_ttl(),
252+
expiry_timestamp=self._durable_ttl(name),
217253
)
218254
try:
219255
self._put_record(record, condition="half_open", expected_opened_at=opened_at)
@@ -228,7 +264,7 @@ def save_closed(self, name: str) -> None:
228264
name=name,
229265
state=CircuitState.CLOSED,
230266
failure_count=0,
231-
expiry_timestamp=self._durable_ttl(),
267+
expiry_timestamp=self._durable_ttl(name),
232268
)
233269
self._update_record(record)
234270
self._save_to_cache(record)
@@ -245,7 +281,7 @@ def save_reopen(self, name: str, opened_at: int) -> None:
245281
name=name,
246282
state=CircuitState.OPEN,
247283
opened_at=opened_at,
248-
expiry_timestamp=self._durable_ttl(),
284+
expiry_timestamp=self._durable_ttl(name),
249285
)
250286
self._update_record(record)
251287
self._save_to_cache(record)

aws_lambda_powertools/utilities/circuit_breaker_alpha/persistence/record.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ class CircuitStateRecord:
3030
Unix timestamp (seconds) the circuit opened. Anchors the recovery timeout;
3131
``None`` while closed.
3232
half_open_owner : str | None
33-
Identifier of the execution environment that won the half-open probe lock, if any.
33+
Identifier of the worker (one thread in one execution environment) that won the
34+
half-open probe lock, if any.
3435
expiry_timestamp : int | None
3536
Unix timestamp (seconds) for the store's TTL attribute.
3637
"""

docs/utilities/circuit_breaker.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ The circuit breaker utility stops sending traffic to an unhealthy downstream dep
2020
* Hands rejected requests to an `on_circuit_open` callback so you decide what happens next (buffer, drop, return a cached value)
2121
* Tests recovery with an explicit half-open probe rather than blindly retrying everything at once
2222
* Shares circuit state across execution environments via Amazon DynamoDB
23+
* Safe under concurrency: one half-open probe across all threads and execution environments, and synchronized failure counting
2324
* Keeps the healthy path write-free: failures are counted in memory and only persisted on a state transition
2425

2526
## Terminology
@@ -182,6 +183,19 @@ Passing both raises `CircuitBreakerConfigError`. An exception that doesn't count
182183

183184
After `recovery_timeout` seconds, the circuit moves to `HALF_OPEN` and elects a **single** execution environment (via a conditional DynamoDB write) to run a probe. If `success_threshold` consecutive probes succeed, the circuit closes; a single failing probe reopens it. This stops a thundering herd of every environment hammering a recovering backend at once.
184185

186+
!!! note "Thread safety"
187+
The utility is safe to share across threads: within a multi-threaded environment the probe election picks a single
188+
thread, so the single-prober guarantee spans threads as well as environments, and the in-memory failure counter is
189+
synchronized. Single-threaded functions (the normal Lambda model) are unaffected.
190+
191+
Probe ownership belongs to the thread that won the election. If that thread never runs the circuit again (for
192+
example, a thread-per-request worker pool), recovery waits for the probe lease to expire before another thread or
193+
environment takes over.
194+
195+
!!! warning "Make your hooks thread-safe"
196+
If your function runs multiple threads, `on_circuit_open` and `on_transition` callbacks can run concurrently
197+
for the same circuit. Make them thread-safe.
198+
185199
### State coordination across environments
186200

187201
The consecutive-failure counter lives in memory per execution environment, so a healthy circuit performs **no writes**. Only when an environment reaches `failure_threshold` does it persist `OPEN`. The shared state is cached locally for `local_cache_max_age` seconds to avoid a read per invocation. A cache miss (cold start or expired entry) forces a read-through before routing.

0 commit comments

Comments
 (0)