Skip to content

fix(flags): Fix bug where concurrent accesses to the flags property could raise a RunTime error #4034

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions sentry_sdk/feature_flags.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import copy
import sentry_sdk
from sentry_sdk._lru_cache import LRUCache
from threading import Lock

from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from typing import TypedDict
Expand All @@ -16,20 +18,44 @@ class FlagBuffer:

def __init__(self, capacity):
# type: (int) -> None
self.buffer = LRUCache(capacity)
self.capacity = capacity
self.lock = Lock()

# Buffer is private. The name is mangled to discourage use. If you use this attribute
# directly you're on your own!
self.__buffer = LRUCache(capacity)

def clear(self):
# type: () -> None
self.buffer = LRUCache(self.capacity)
self.__buffer = LRUCache(self.capacity)

def __deepcopy__(self, memo):
# type: (dict[int, Any]) -> FlagBuffer
with self.lock:
buffer = FlagBuffer(self.capacity)
buffer.__buffer = copy.deepcopy(self.__buffer, memo)
return buffer

def get(self):
# type: () -> list[FlagData]
return [{"flag": key, "result": value} for key, value in self.buffer.get_all()]
with self.lock:
return [
{"flag": key, "result": value} for key, value in self.__buffer.get_all()
]

def set(self, flag, result):
# type: (str, bool) -> None
self.buffer.set(flag, result)
if isinstance(result, FlagBuffer):
# If someone were to insert `self` into `self` this would create a circular dependency
# on the lock. This is of course a deadlock. However, this is far outside the expected
# usage of this class. We guard against it here for completeness and to document this
# expected failure mode.
raise ValueError(
"FlagBuffer instances can not be inserted into the dictionary."
)

with self.lock:
self.__buffer.set(flag, result)


def add_feature_flag(flag, result):
Expand Down
34 changes: 34 additions & 0 deletions tests/test_feature_flags.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import concurrent.futures as cf
import sys
import copy
import threading

import pytest

Expand Down Expand Up @@ -167,3 +169,35 @@ def test_flag_tracking():
{"flag": "e", "result": False},
{"flag": "f", "result": False},
]


def test_flag_buffer_concurrent_access():
buffer = FlagBuffer(capacity=100)
error_occurred = False

def writer():
for i in range(1_000_000):
buffer.set(f"key_{i}", True)

def reader():
nonlocal error_occurred

try:
for _ in range(1000):
copy.deepcopy(buffer)
except RuntimeError:
error_occurred = True

writer_thread = threading.Thread(target=writer)
reader_thread = threading.Thread(target=reader)

writer_thread.start()
reader_thread.start()

writer_thread.join(timeout=5)
reader_thread.join(timeout=5)

# This should always be false. If this ever fails we know we have concurrent access to a
# shared resource. When deepcopying we should have exclusive access to the underlying
# memory.
assert error_occurred is False
Loading