fix(telemetry-report): stream remote rows as JSON-lines to avoid droplet OOM

fetch_rows_remote loaded every row in the window into a list and
json.dumps'd the whole payload on the remote side; on the 1GB
pulse-license droplet a 30-day window over a 104k-row DB got the
process OOM-killed. The remote helper now prints a db_stats header
line and then one JSON line per row while iterating the cursor, so
it never holds the full result set. Local parsing updated to match
and covered by unit tests; fetch_rows_local is unchanged.

Verified end to end against the droplet with --since-days 30.

Contract-Neutral: fetch_rows_remote OOM fix: stream JSON-lines instead of one giant payload; no contract delta, report output unchanged
This commit is contained in:
rcourtman 2026-07-17 23:17:14 +01:00
parent 9e88c0dfcb
commit 1e267566af
2 changed files with 34 additions and 6 deletions

View file

@ -1138,6 +1138,8 @@ def fetch_rows_local(db_path: str, since_days: int) -> dict[str, Any]:
def fetch_rows_remote(ssh_host: str, db_path: str, since_days: int) -> dict[str, Any]:
# Streams JSON-lines (db_stats header, then one row per line) so the remote
# process never holds the full result set — the license droplet has 1GB RAM.
remote_script = """
import json
import sqlite3
@ -1161,11 +1163,9 @@ rows_sql = (
)
try:
db_stats = dict(conn.execute(db_stats_sql).fetchone())
rows = [
dict(row)
for row in conn.execute(rows_sql, (since_days,)).fetchall()
]
print(json.dumps({"db_stats": db_stats, "rows": rows}))
print(json.dumps({"db_stats": db_stats}))
for row in conn.execute(rows_sql, (since_days,)):
print(json.dumps(dict(row)))
finally:
conn.close()
"""
@ -1176,7 +1176,12 @@ finally:
capture_output=True,
check=True,
)
return json.loads(result.stdout)
lines = (line for line in result.stdout.splitlines() if line.strip())
try:
header = json.loads(next(lines))
except StopIteration:
raise RuntimeError(f"empty response from remote telemetry fetch on {ssh_host}") from None
return {"db_stats": header["db_stats"], "rows": [json.loads(line) for line in lines]}
def counter_entries(counter: Counter[str], key_name: str) -> list[dict[str, Any]]: