Skip to content

Commit 0aeb61a

Browse files
authored
fix(performance): exclude db.cursor spans from N+1 DB detection (#118031)
Cursor iteration in asyncpg (and other DB drivers that support server-side cursors) produces N spans that share the original SQL as their description. Each `cursor.fetch()` and `cursor.__anext__` calls trigger a `BaseCursor._exec` invocation, which the SDK wraps as a `db` span — so the detector sees a source span followed by N identical repeating spans and fires a false-positive N+1 alert. These are sequential calls draining a **pre-existing server-side cursor result set**, not N independently issued queries. They should be excluded from N+1 detection the same way `db.redis` and `db.connection` already are.
1 parent f1d0af2 commit 0aeb61a

2 files changed

Lines changed: 71 additions & 0 deletions

File tree

src/sentry/issue_detection/detectors/n_plus_one_db_span_detector.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ def _is_db_op(self, op: str) -> bool:
123123
op.startswith("db")
124124
and not op.startswith("db.redis")
125125
and not op.startswith("db.connection")
126+
and not op.startswith("db.cursor")
126127
)
127128

128129
def _maybe_use_as_source(self, span: Span) -> None:

tests/sentry/issue_detection/test_n_plus_one_db_span_detector.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,76 @@ def test_does_not_detect_n_plus_one_with_cached_queries(self) -> None:
427427

428428
assert self.find_problems(event) == []
429429

430+
def test_does_not_detect_n_plus_one_with_cursor_iter_spans(self) -> None:
431+
"""
432+
Cursor iteration (e.g. asyncpg BaseCursor._exec on each cursor.__anext__())
433+
produces N spans with the original SQL as their description. These are
434+
sequential calls draining a pre-existing server-side cursor, not
435+
N independently issued queries, so they should not trigger the N+1
436+
detector.
437+
"""
438+
source_span = create_span(
439+
"db",
440+
100,
441+
"SELECT * FROM table WHERE id = %s",
442+
hash="source_hash",
443+
)
444+
445+
repeating_spans = [
446+
create_span(
447+
"db.cursor.iter",
448+
100,
449+
"SELECT * FROM table WHERE id = %s",
450+
hash="cursor_iter_hash",
451+
)
452+
for _ in range(11)
453+
]
454+
455+
event = create_event([source_span] + repeating_spans)
456+
event["contexts"] = {
457+
"trace": {
458+
"span_id": "a" * 16,
459+
"op": "http.server",
460+
}
461+
}
462+
463+
assert self.find_problems(event) == []
464+
465+
def test_does_not_detect_n_plus_one_with_cursor_fetch_spans(self) -> None:
466+
"""
467+
Cursor iteration via FETCH calls (e.g. through asyncpg Cursor.fetch)
468+
produces N spans with the original SQL as their description. These are
469+
sequential calls draining a pre-existing server-side cursor, not
470+
N independently issued queries, so they should not trigger the N+1
471+
detector.
472+
"""
473+
source_span = create_span(
474+
"db",
475+
100,
476+
"SELECT * FROM table WHERE id = %s",
477+
hash="source_hash",
478+
)
479+
480+
repeating_spans = [
481+
create_span(
482+
"db.cursor.fetch",
483+
100,
484+
"SELECT * FROM table WHERE id = %s",
485+
hash="cursor_fetch_hash",
486+
)
487+
for _ in range(11)
488+
]
489+
490+
event = create_event([source_span] + repeating_spans)
491+
event["contexts"] = {
492+
"trace": {
493+
"span_id": "a" * 16,
494+
"op": "http.server",
495+
}
496+
}
497+
498+
assert self.find_problems(event) == []
499+
430500

431501
@pytest.mark.django_db
432502
class NPlusOneDbSettingTest(TestCase):

0 commit comments

Comments
 (0)