Skip to content

Commit 6591e39

Browse files
Ignacio Van Droogenbroeckclaude
andcommitted
debug: Add systematic memory leak investigation script
This script tests three hypotheses: 1. DuckDB cursor.close() - does it matter? 2. FastAPI response objects - are they collectible? 3. Tracemalloc - where are allocations happening? Run in production container: docker exec -it <container> python3 debug_memory.py 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 45ee912 commit 6591e39

1 file changed

Lines changed: 211 additions & 0 deletions

File tree

debug_memory.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Systematic memory leak investigation script.
4+
Run this inside the Arc container to trace memory usage.
5+
"""
6+
7+
import sys
8+
import gc
9+
import os
10+
import tracemalloc
11+
import time
12+
import duckdb
13+
14+
def measure_memory():
15+
"""Get current RSS memory in MB"""
16+
try:
17+
import resource
18+
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
19+
except:
20+
return 0
21+
22+
def test_duckdb_cursor_leak():
23+
"""Test if DuckDB cursor needs explicit close"""
24+
print("\n" + "="*60)
25+
print("TEST 1: DuckDB Cursor Leak")
26+
print("="*60)
27+
28+
conn = duckdb.connect(':memory:')
29+
30+
# Create test data
31+
conn.execute("CREATE TABLE test AS SELECT range as x FROM range(10000)")
32+
33+
mem_before = measure_memory()
34+
print(f"Memory before queries: {mem_before:.1f} MB")
35+
36+
# Test WITHOUT cursor.close()
37+
print("\nRunning 10 queries WITHOUT cursor.close()...")
38+
for i in range(10):
39+
result = conn.execute("SELECT * FROM test").fetchall()
40+
data = [list(row) for row in result]
41+
del result
42+
del data
43+
44+
gc.collect()
45+
mem_after_no_close = measure_memory()
46+
print(f"Memory after (no close): {mem_after_no_close:.1f} MB")
47+
print(f"Growth: {mem_after_no_close - mem_before:.1f} MB")
48+
49+
# Reset
50+
conn.close()
51+
gc.collect()
52+
time.sleep(1)
53+
54+
conn = duckdb.connect(':memory:')
55+
conn.execute("CREATE TABLE test AS SELECT range as x FROM range(10000)")
56+
57+
mem_before = measure_memory()
58+
print(f"\nMemory before queries: {mem_before:.1f} MB")
59+
60+
# Test WITH cursor.close()
61+
print("Running 10 queries WITH cursor.close()...")
62+
for i in range(10):
63+
cursor = conn.execute("SELECT * FROM test")
64+
result = cursor.fetchall()
65+
data = [list(row) for row in result]
66+
del result
67+
cursor.close()
68+
del cursor
69+
del data
70+
71+
gc.collect()
72+
mem_after_with_close = measure_memory()
73+
print(f"Memory after (with close): {mem_after_with_close:.1f} MB")
74+
print(f"Growth: {mem_after_with_close - mem_before:.1f} MB")
75+
76+
conn.close()
77+
78+
print(f"\n✓ Difference: {(mem_after_no_close - mem_before) - (mem_after_with_close - mem_before):.1f} MB")
79+
if abs((mem_after_no_close - mem_before) - (mem_after_with_close - mem_before)) < 1:
80+
print("⚠ cursor.close() makes NO difference - not the leak!")
81+
else:
82+
print("✓ cursor.close() DOES help - this is part of the leak")
83+
84+
def test_fastapi_response_leak():
85+
"""Test if FastAPI response holds references"""
86+
print("\n" + "="*60)
87+
print("TEST 2: FastAPI Response Leak")
88+
print("="*60)
89+
90+
from pydantic import BaseModel
91+
from typing import List
92+
from datetime import datetime
93+
94+
class QueryResponse(BaseModel):
95+
success: bool
96+
columns: List[str]
97+
data: List[List]
98+
row_count: int
99+
timestamp: datetime
100+
101+
mem_before = measure_memory()
102+
print(f"Memory before: {mem_before:.1f} MB")
103+
104+
responses = []
105+
106+
print("Creating 100 response objects with 100 rows each...")
107+
for i in range(100):
108+
# Simulate query result
109+
result = {
110+
"columns": ["col1", "col2", "col3"],
111+
"data": [[i, i*2, i*3] for _ in range(100)],
112+
"row_count": 100
113+
}
114+
115+
response = QueryResponse(
116+
success=True,
117+
columns=result["columns"],
118+
data=result["data"],
119+
row_count=result["row_count"],
120+
timestamp=datetime.now()
121+
)
122+
123+
# Simulate what happens in production - response is returned
124+
# but we keep creating new ones
125+
del result
126+
127+
mem_after = measure_memory()
128+
print(f"Memory after: {mem_after:.1f} MB")
129+
print(f"Growth: {mem_after - mem_before:.1f} MB")
130+
131+
print("\nRunning GC...")
132+
collected = gc.collect()
133+
mem_after_gc = measure_memory()
134+
print(f"GC collected {collected} objects")
135+
print(f"Memory after GC: {mem_after_gc:.1f} MB")
136+
print(f"Memory freed by GC: {mem_after - mem_after_gc:.1f} MB")
137+
138+
if mem_after_gc < mem_after:
139+
print("✓ GC freed some memory - objects were collectible")
140+
else:
141+
print("⚠ GC freed nothing - objects are still referenced somewhere")
142+
143+
def test_tracemalloc():
144+
"""Use tracemalloc to find where allocations happen"""
145+
print("\n" + "="*60)
146+
print("TEST 3: Tracemalloc - Find Allocation Sources")
147+
print("="*60)
148+
149+
tracemalloc.start()
150+
151+
# Simulate a query execution
152+
conn = duckdb.connect(':memory:')
153+
conn.execute("CREATE TABLE test AS SELECT range as x FROM range(1000)")
154+
155+
snapshot1 = tracemalloc.take_snapshot()
156+
157+
# Execute query 10 times
158+
for i in range(10):
159+
cursor = conn.execute("SELECT * FROM test")
160+
result = cursor.fetchall()
161+
data = [list(row) for row in result]
162+
del result
163+
cursor.close()
164+
del cursor
165+
del data
166+
167+
snapshot2 = tracemalloc.take_snapshot()
168+
169+
top_stats = snapshot2.compare_to(snapshot1, 'lineno')
170+
171+
print("\nTop 10 memory allocations:")
172+
for stat in top_stats[:10]:
173+
print(f"{stat.size_diff / 1024:.1f} KB: {stat}")
174+
175+
tracemalloc.stop()
176+
conn.close()
177+
178+
def main():
179+
print("="*60)
180+
print("Arc Memory Leak Investigation")
181+
print("="*60)
182+
print(f"Python version: {sys.version}")
183+
print(f"Process PID: {os.getpid()}")
184+
185+
try:
186+
test_duckdb_cursor_leak()
187+
except Exception as e:
188+
print(f"❌ Test 1 failed: {e}")
189+
import traceback
190+
traceback.print_exc()
191+
192+
try:
193+
test_fastapi_response_leak()
194+
except Exception as e:
195+
print(f"❌ Test 2 failed: {e}")
196+
import traceback
197+
traceback.print_exc()
198+
199+
try:
200+
test_tracemalloc()
201+
except Exception as e:
202+
print(f"❌ Test 3 failed: {e}")
203+
import traceback
204+
traceback.print_exc()
205+
206+
print("\n" + "="*60)
207+
print("Investigation Complete")
208+
print("="*60)
209+
210+
if __name__ == "__main__":
211+
main()

0 commit comments

Comments
 (0)