Skip to content

Commit 02a5b45

Browse files
fix cursor.scroll() position tracking in RECORD mode
1 parent 15a32a8 commit 02a5b45

3 files changed

Lines changed: 52 additions & 54 deletions

File tree

drift/instrumentation/psycopg/e2e-tests/src/app.py

Lines changed: 28 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -414,58 +414,11 @@ def test_statusmessage():
414414
except Exception as e:
415415
return jsonify({"error": str(e)}), 500
416416

417-
418-
419-
# ============================================================================
420-
# CONFIRMED BUG TEST ENDPOINTS
421-
# These endpoints expose confirmed bugs in the psycopg instrumentation.
422-
# See BUG_TRACKING.md for detailed documentation of each bug.
423-
#
424-
# Bug Summary:
425-
# 1. /test/cursor-scroll - scroll() broken during RECORD mode
426-
# 4. /test/nextset - nextset() iteration broken during RECORD mode
427-
# 5. /test/server-cursor-scroll - scroll() broken during RECORD mode
428-
# ============================================================================
429-
430-
@app.route("/test/cursor-scroll")
431-
def test_cursor_scroll():
432-
"""Test cursor.scroll() method.
433-
434-
BUG: During RECORD mode, the instrumentation's _finalize_query_span calls
435-
fetchall() which breaks the cursor position tracking. After fetchall(),
436-
scroll(0, absolute) doesn't properly reset the cursor position because
437-
the patched fetch methods use _tusk_index instead of _pos.
438-
"""
439-
try:
440-
with psycopg.connect(get_conn_string()) as conn, conn.cursor() as cur:
441-
cur.execute("SELECT id, name FROM users ORDER BY id")
442-
443-
# Fetch first row
444-
first = cur.fetchone()
445-
446-
# Scroll back to start
447-
cur.scroll(0, mode='absolute')
448-
449-
# Fetch first row again
450-
first_again = cur.fetchone()
451-
452-
return jsonify({
453-
"first": {"id": first[0], "name": first[1]} if first else None,
454-
"first_again": {"id": first_again[0], "name": first_again[1]} if first_again else None,
455-
"match": first == first_again
456-
})
457-
except Exception as e:
458-
return jsonify({"error": str(e)}), 500
459-
460417
@app.route("/test/nextset")
461418
def test_nextset():
462419
"""Test cursor.nextset() for multiple result sets.
463420
464-
BUG: During RECORD mode, the interaction between executemany with
465-
returning=True and the fetch method patching breaks nextset() iteration.
466-
The instrumentation's fetch patching may consume results before nextset()
467-
can iterate through them, causing 0 results in RECORD but correct
468-
results in REPLAY.
421+
Tests whether the instrumentation correctly handles nextset() for multiple result sets.
469422
"""
470423
try:
471424
with psycopg.connect(get_conn_string()) as conn, conn.cursor() as cur:
@@ -497,16 +450,38 @@ def test_nextset():
497450
except Exception as e:
498451
return jsonify({"error": str(e)}), 500
499452

453+
@app.route("/test/cursor-scroll")
454+
def test_cursor_scroll():
455+
"""Test cursor.scroll() method.
456+
457+
Tests whether the instrumentation correctly handles scroll() for cursor position tracking.
458+
"""
459+
try:
460+
with psycopg.connect(get_conn_string()) as conn, conn.cursor() as cur:
461+
cur.execute("SELECT id, name FROM users ORDER BY id")
462+
463+
# Fetch first row
464+
first = cur.fetchone()
465+
466+
# Scroll back to start
467+
cur.scroll(0, mode='absolute')
468+
469+
# Fetch first row again
470+
first_again = cur.fetchone()
471+
472+
return jsonify({
473+
"first": {"id": first[0], "name": first[1]} if first else None,
474+
"first_again": {"id": first_again[0], "name": first_again[1]} if first_again else None,
475+
"match": first == first_again
476+
})
477+
except Exception as e:
478+
return jsonify({"error": str(e)}), 500
500479

501480
@app.route("/test/server-cursor-scroll")
502481
def test_server_cursor_scroll():
503482
"""Test ServerCursor.scroll() method.
504483
505-
BUG: Same root cause as /test/cursor-scroll. During RECORD mode,
506-
the instrumentation breaks scroll() functionality by consuming all
507-
rows via fetchall() in _finalize_query_span. ServerCursor.scroll()
508-
sends MOVE commands to the server, but the position tracking is
509-
inconsistent after the instrumentation patches the fetch methods.
484+
Tests whether the instrumentation correctly handles scroll() for server-side cursors.
510485
"""
511486
try:
512487
with psycopg.connect(get_conn_string()) as conn:

drift/instrumentation/psycopg/e2e-tests/src/test_requests.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,10 @@ def make_request(method, endpoint, **kwargs):
8686

8787
make_request("GET", "/test/statusmessage")
8888

89-
# Bug-exposing test endpoints
9089
make_request("GET", "/test/nextset")
90+
9191
make_request("GET", "/test/server-cursor-scroll")
92+
9293
make_request("GET", "/test/cursor-scroll")
9394

9495
print("\nAll requests completed successfully")

drift/instrumentation/psycopg/instrumentation.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,8 @@ def _record_execute(
857857
cursor.fetchall = cursor._tusk_original_fetchall
858858
cursor._tusk_rows = None
859859
cursor._tusk_index = 0
860+
if hasattr(cursor, '_tusk_original_scroll'):
861+
cursor.scroll = cursor._tusk_original_scroll
860862

861863
span_info = SpanUtils.create_span(
862864
CreateSpanOptions(
@@ -2090,16 +2092,36 @@ def patched_fetchall():
20902092
cursor._tusk_index = len(cursor._tusk_rows) # pyright: ignore[reportAttributeAccessIssue]
20912093
return result
20922094

2095+
def patched_scroll(value: int, mode: str = "relative") -> None:
2096+
"""Scroll the cursor to a new position in the captured result set."""
2097+
if mode == "relative":
2098+
newpos = cursor._tusk_index + value # pyright: ignore[reportAttributeAccessIssue]
2099+
elif mode == "absolute":
2100+
newpos = value
2101+
else:
2102+
raise ValueError(f"bad mode: {mode}. It should be 'relative' or 'absolute'")
2103+
2104+
num_rows = len(cursor._tusk_rows) # pyright: ignore[reportAttributeAccessIssue]
2105+
if num_rows > 0:
2106+
if not (0 <= newpos < num_rows):
2107+
raise IndexError("cursor position out of range")
2108+
elif newpos != 0:
2109+
raise IndexError("cursor position out of range")
2110+
2111+
cursor._tusk_index = newpos # pyright: ignore[reportAttributeAccessIssue]
2112+
20932113
# Save original fetch methods before patching (only if not already saved)
20942114
# These will be restored at the start of the next execute() call
20952115
if not hasattr(cursor, '_tusk_original_fetchone'):
20962116
cursor._tusk_original_fetchone = cursor.fetchone # pyright: ignore[reportAttributeAccessIssue]
20972117
cursor._tusk_original_fetchmany = cursor.fetchmany # pyright: ignore[reportAttributeAccessIssue]
20982118
cursor._tusk_original_fetchall = cursor.fetchall # pyright: ignore[reportAttributeAccessIssue]
2119+
cursor._tusk_original_scroll = cursor.scroll # pyright: ignore[reportAttributeAccessIssue]
20992120

21002121
cursor.fetchone = patched_fetchone # pyright: ignore[reportAttributeAccessIssue]
21012122
cursor.fetchmany = patched_fetchmany # pyright: ignore[reportAttributeAccessIssue]
21022123
cursor.fetchall = patched_fetchall # pyright: ignore[reportAttributeAccessIssue]
2124+
cursor.scroll = patched_scroll # pyright: ignore[reportAttributeAccessIssue]
21032125

21042126
except Exception as fetch_error:
21052127
logger.debug(f"Could not fetch rows (query might not return rows): {fetch_error}")

0 commit comments

Comments
 (0)