Skip to content

Commit 04d2596

Browse files
committed
Fix concurrent access bug
1 parent d670a15 commit 04d2596

File tree

2 files changed

+63
-4
lines changed

2 files changed

+63
-4
lines changed

sentry_sdk/feature_flags.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import copy
12
import sentry_sdk
23
from sentry_sdk._lru_cache import LRUCache
4+
from threading import Lock
35

46
from typing import TYPE_CHECKING
57

@@ -16,20 +18,43 @@ class FlagBuffer:
1618

1719
def __init__(self, capacity):
1820
# type: (int) -> None
19-
self.buffer = LRUCache(capacity)
2021
self.capacity = capacity
22+
self.lock = Lock()
23+
24+
# Buffer is private. The name is mangled to discourage use. If you use this attribute
25+
# directly you're on your own!
26+
self.__buffer = LRUCache(capacity)
2127

2228
def clear(self):
2329
# type: () -> None
24-
self.buffer = LRUCache(self.capacity)
30+
self.__buffer = LRUCache(self.capacity)
31+
32+
def __deepcopy__(self, memo):
33+
with self.lock:
34+
buffer = FlagBuffer(self.capacity)
35+
buffer.__buffer = copy.deepcopy(self.__buffer, memo)
36+
return buffer
2537

2638
def get(self):
2739
# type: () -> list[FlagData]
28-
return [{"flag": key, "result": value} for key, value in self.buffer.get_all()]
40+
with self.lock:
41+
return [
42+
{"flag": key, "result": value} for key, value in self.__buffer.get_all()
43+
]
2944

3045
def set(self, flag, result):
3146
# type: (str, bool) -> None
32-
self.buffer.set(flag, result)
47+
if isinstance(result, FlagBuffer):
48+
# If someone were to `self` in `self` this would create a circular dependency on
49+
# the lock. This is of course a deadlock. However, this is far outside the expected
50+
# usage of this class. We guard against it here for completeness and to document this
51+
# expected failure mode.
52+
raise ValueError(
53+
"FlagBuffer instances can not be inserted into the dictionary."
54+
)
55+
56+
with self.lock:
57+
self.__buffer.set(flag, result)
3358

3459

3560
def add_feature_flag(flag, result):

tests/test_feature_flags.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import concurrent.futures as cf
22
import sys
3+
import copy
4+
import threading
35

46
import pytest
57

@@ -167,3 +169,35 @@ def test_flag_tracking():
167169
{"flag": "e", "result": False},
168170
{"flag": "f", "result": False},
169171
]
172+
173+
174+
def test_flag_buffer_concurrent_access():
175+
buffer = FlagBuffer(capacity=100)
176+
error_occurred = False
177+
178+
def writer():
179+
for i in range(1_000_000):
180+
buffer.set(f"key_{i}", True)
181+
182+
def reader():
183+
nonlocal error_occurred
184+
185+
try:
186+
for _ in range(1000):
187+
copy.deepcopy(buffer)
188+
except RuntimeError:
189+
error_occurred = True
190+
191+
writer_thread = threading.Thread(target=writer)
192+
reader_thread = threading.Thread(target=reader)
193+
194+
writer_thread.start()
195+
reader_thread.start()
196+
197+
writer_thread.join(timeout=5)
198+
reader_thread.join(timeout=5)
199+
200+
# This should always be false. If this ever fails we know we have concurrent access to a
201+
# shared resource. When deepcopying we should have exclusive access to the underlying
202+
# memory.
203+
assert error_occurred is False

0 commit comments

Comments
 (0)