Skip to content

Commit 0eaec8c

Browse files
ZStriker19wconti27
andauthored
fix: guard against django cache result errors when setting row_count [backport #5723 to 1.12] (#5789)
Backport of #5723 to 1.12 Patch for issue: #5460. Added try except block to catch potential errors that may occur. Added tests to verify patch works as intended to catch errors. ## Checklist - [x] Change(s) are motivated and described in the PR description. - [x] Testing strategy is described if automated tests are not included in the PR. - [x] Risk is outlined (performance impact, potential for breakage, maintainability, etc). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] [Library release note guidelines](https://ddtrace.readthedocs.io/en/stable/contributing.html#Release-Note-Guidelines) are followed. - [x] Documentation is included (in-code, generated user docs, [public corp docs](https://github.com/DataDog/documentation/)). - [x] PR description includes explicit acknowledgement/acceptance of the performance implications of this PR as reported in the benchmarks PR comment. ## Reviewer Checklist - [x] Title is accurate. - [x] No unnecessary changes are introduced. - [x] Description motivates each change. - [x] Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes unless absolutely necessary. - [x] Testing strategy adequately addresses listed risk(s). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] Release note makes sense to a user of the library. - [x] Reviewer has explicitly acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment. Co-authored-by: William Conti <[email protected]>
1 parent 4d97efb commit 0eaec8c

File tree

3 files changed

+139
-6
lines changed

3 files changed

+139
-6
lines changed

ddtrace/contrib/django/patch.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,16 @@ def traced_cache(django, pin, func, instance, args, kwargs):
177177
db.ROWCOUNT, sum(1 for doc in result if doc) if result and isinstance(result, Iterable) else 0
178178
)
179179
elif command_name == "get":
180-
# if valid result and check for special case for Django~3.0 that returns an empty Sentinel object as
181-
# missing key
182-
if result is not None and result != getattr(instance, "_missing_key", None):
183-
span.set_metric(db.ROWCOUNT, 1)
184-
# else result is invalid or None, set row count to 0
185-
else:
180+
try:
181+
# check also for special case for Django~3.2 that returns an empty Sentinel object for empty results
182+
# also check if result is Iterable first since some iterables return ambiguous truth results with ``==``
183+
if result is None or (
184+
not isinstance(result, Iterable) and result == getattr(instance, "_missing_key", None)
185+
):
186+
span.set_metric(db.ROWCOUNT, 0)
187+
else:
188+
span.set_metric(db.ROWCOUNT, 1)
189+
except (AttributeError, NotImplementedError, ValueError):
186190
span.set_metric(db.ROWCOUNT, 0)
187191
return result
188192

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
fixes:
3+
- |
4+
django: Adds catch to guard against a ValueError, AttributeError, or NotImplementedError from being thrown when evaluating
5+
a django cache result for ``db.row_count`` tag.

tests/contrib/django/test_django.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,130 @@ def test_cache_get_rowcount_missing_key_with_default(test_spans):
811811
assert_dict_issuperset(span.get_metrics(), {"db.row_count": 1})
812812

813813

814+
class RaiseNotImplementedError:
815+
def __eq__(self, _):
816+
raise NotImplementedError
817+
818+
819+
class RaiseValueError:
820+
def __eq__(self, _):
821+
raise ValueError
822+
823+
824+
class RaiseAttributeError:
825+
def __eq__(self, _):
826+
raise AttributeError
827+
828+
829+
def test_cache_get_rowcount_throws_attribute_and_value_error(test_spans):
830+
831+
# get the default cache
832+
cache = django.core.cache.caches["default"]
833+
834+
cache.set(1, RaiseNotImplementedError())
835+
cache.set(2, RaiseValueError())
836+
cache.set(3, RaiseAttributeError())
837+
838+
# This is the diff with `test_cache_get_rowcount_missing_key`,
839+
# we are setting a default value to be returned in case of a cache miss
840+
result = cache.get(1)
841+
assert isinstance(result, RaiseNotImplementedError)
842+
843+
result = cache.get(2)
844+
assert isinstance(result, RaiseValueError)
845+
846+
result = cache.get(3)
847+
assert isinstance(result, RaiseAttributeError)
848+
849+
spans = test_spans.get_spans()
850+
assert len(spans) == 6
851+
852+
set_1 = spans[0]
853+
set_2 = spans[1]
854+
set_3 = spans[2]
855+
assert set_1.resource == "django.core.cache.backends.locmem.set"
856+
assert set_2.resource == "django.core.cache.backends.locmem.set"
857+
assert set_3.resource == "django.core.cache.backends.locmem.set"
858+
859+
get_1 = spans[3]
860+
assert get_1.service == "django"
861+
assert get_1.resource == "django.core.cache.backends.locmem.get"
862+
assert_dict_issuperset(get_1.get_metrics(), {"db.row_count": 0})
863+
864+
get_2 = spans[4]
865+
assert get_2.service == "django"
866+
assert get_2.resource == "django.core.cache.backends.locmem.get"
867+
assert_dict_issuperset(get_2.get_metrics(), {"db.row_count": 0})
868+
869+
get_3 = spans[5]
870+
assert get_3.service == "django"
871+
assert get_3.resource == "django.core.cache.backends.locmem.get"
872+
assert_dict_issuperset(get_3.get_metrics(), {"db.row_count": 0})
873+
874+
875+
class MockDataFrame:
876+
def __init__(self, data):
877+
self.data = data
878+
879+
def __eq__(self, other):
880+
if isinstance(other, str):
881+
return MockDataFrame([item == other for item in self.data])
882+
else:
883+
return MockDataFrame([row == other for row in self.data])
884+
885+
def __bool__(self):
886+
raise ValueError("Cannot determine truthiness of comparison result for DataFrame.")
887+
888+
def __iter__(self):
889+
return iter(self.data)
890+
891+
892+
@pytest.mark.skipif(django.VERSION < (2, 0, 0), reason="")
893+
def test_cache_get_rowcount_iterable_ambiguous_truthiness(test_spans):
894+
# get the default cache
895+
896+
data = {"col1": 1, "col2": 2, "col3": 3}
897+
898+
cache = django.core.cache.caches["default"]
899+
cache.set(1, MockDataFrame(data))
900+
cache.set(2, None)
901+
902+
# throw error to verify that mock class has ambigiuous truthiness, django patch will do a similar
903+
# set of comparisons when trying to get rowcount
904+
with pytest.raises(ValueError):
905+
df = MockDataFrame(data)
906+
assert df is not None
907+
check_result = df == "some_result"
908+
if check_result:
909+
print("This should never print.")
910+
911+
# Test to ensure that a DF does not throw a bool error when trying to
912+
# determine if the result was valid.
913+
result = cache.get(1)
914+
assert isinstance(result, MockDataFrame)
915+
916+
result = cache.get(2)
917+
assert result is None
918+
919+
spans = test_spans.get_spans()
920+
assert len(spans) == 4
921+
922+
set_1 = spans[0]
923+
set_2 = spans[1]
924+
assert set_1.resource == "django.core.cache.backends.locmem.set"
925+
assert set_2.resource == "django.core.cache.backends.locmem.set"
926+
927+
get_1 = spans[2]
928+
assert get_1.service == "django"
929+
assert get_1.resource == "django.core.cache.backends.locmem.get"
930+
assert_dict_issuperset(get_1.get_metrics(), {"db.row_count": 1})
931+
932+
get_2 = spans[3]
933+
assert get_2.service == "django"
934+
assert get_2.resource == "django.core.cache.backends.locmem.get"
935+
assert_dict_issuperset(get_2.get_metrics(), {"db.row_count": 0})
936+
937+
814938
def test_cache_get_unicode(test_spans):
815939
# get the default cache
816940
cache = django.core.cache.caches["default"]

0 commit comments

Comments
 (0)