Skip to content

Commit f72a68f

Browse files
authored
perf: batch health-check writes, PRAGMA synchronous=NORMAL, concurrent exchange fetch (CashPilot-perf) (#123)
From the research performance panel — three low-risk hot-path fixes: 1. Health-check cycle committed one health event PER service (up to ~49 fsync'd commits/cycle, 288 cycles/day). Collect the cycle's events and write them in a single transaction via new database.record_health_events (one executemany + one commit) — 27-98x fewer commits on the write-heaviest path. 2. PRAGMA synchronous=NORMAL in _get_db: durable in WAL across app crashes, skips an fsync on every commit — a fleet-wide win on top of #1. 3. exchange_rates.refresh() fetched CoinGecko then Frankfurter sequentially, awaited on the startup critical path (up to 30s worst case with two 15s timeouts). Fetch both concurrently with asyncio.gather(return_exceptions) — halves the worst case and makes one source's failure not discard the other's success. Behavior-preserving. Tests: record_health_events batched write + empty no-op; the health-check tests updated to the batched contract. Full suite 1173 green; ruff clean.
1 parent 09745be commit f72a68f

6 files changed

Lines changed: 129 additions & 61 deletions

File tree

app/database.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,10 @@ async def _get_db() -> _BorrowedConnection:
280280
await conn.execute("PRAGMA journal_mode=WAL")
281281
await conn.execute("PRAGMA foreign_keys=ON")
282282
await conn.execute("PRAGMA busy_timeout=5000")
283+
# In WAL mode NORMAL is durable across app crashes (only a power loss can lose
284+
# the last transactions) and skips an fsync on every commit — a large win on the
285+
# write-heavy health-check path that commits per service each cycle.
286+
await conn.execute("PRAGMA synchronous=NORMAL")
283287
_shared_conns[key] = conn
284288

285289
return _BorrowedConnection(conn)
@@ -1178,6 +1182,26 @@ async def record_health_event(slug: str, event: str, detail: str = "") -> None:
11781182
await db.close()
11791183

11801184

1185+
async def record_health_events(events: list[tuple[str, str, str]]) -> None:
1186+
"""Record many health events in ONE transaction/commit.
1187+
1188+
The health-check cycle writes one event per deployed service; committing each
1189+
separately fsync'd the WAL up to ~49 times per cycle. One executemany + one commit
1190+
collapses that to a single write — the dominant fix for that path's I/O.
1191+
"""
1192+
if not events:
1193+
return
1194+
db = await _get_db()
1195+
try:
1196+
await db.executemany(
1197+
"INSERT INTO health_events (slug, event, detail) VALUES (?, ?, ?)",
1198+
events,
1199+
)
1200+
await db.commit()
1201+
finally:
1202+
await db.close()
1203+
1204+
11811205
async def get_health_scores(days: int = 7) -> list[dict[str, Any]]:
11821206
"""Compute health score per service over the last N days.
11831207

app/exchange_rates.py

Lines changed: 54 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from __future__ import annotations
1111

12+
import asyncio
1213
import logging
1314
import time
1415
from typing import Any
@@ -52,53 +53,20 @@ async def refresh() -> None:
5253
time -- a non-200 (or an unreachable API) leaves it untouched so staleness is
5354
tracked per-source and a partial failure can't mislabel the other source.
5455
"""
55-
global _fiat_rates, _crypto_usd, _last_fetch, _crypto_last_fetch, _fiat_last_fetch
56+
global _last_fetch
5657

5758
try:
5859
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
59-
# --- Crypto rates from CoinGecko (free, no key) ---
60-
if CRYPTO_IDS:
61-
ids = ",".join(CRYPTO_IDS.values())
62-
resp = await client.get(
63-
"https://api.coingecko.com/api/v3/simple/price",
64-
params={"ids": ids, "vs_currencies": "usd"},
65-
)
66-
if resp.status_code == 200:
67-
data = resp.json()
68-
for token, cg_id in CRYPTO_IDS.items():
69-
price = (data.get(cg_id) or {}).get("usd")
70-
if price is not None:
71-
_crypto_usd[token] = float(price)
72-
_crypto_last_fetch = time.time()
73-
else:
74-
logger.warning(
75-
"exchange rate fetch got HTTP %s from %s",
76-
resp.status_code,
77-
"CoinGecko",
78-
)
79-
else:
80-
# Nothing to fetch -- don't let an empty crypto map hold the
81-
# aggregate staleness clock back forever.
82-
_crypto_last_fetch = time.time()
83-
84-
# --- Fiat rates from Frankfurter (free, no key) ---
85-
resp = await client.get(
86-
"https://api.frankfurter.app/latest",
87-
params={"from": "USD"},
88-
)
89-
if resp.status_code == 200:
90-
data = resp.json()
91-
new_rates: dict[str, float] = {"USD": 1.0}
92-
for code, rate in data.get("rates", {}).items():
93-
new_rates[code] = float(rate)
94-
_fiat_rates = new_rates
95-
_fiat_last_fetch = time.time()
96-
else:
97-
logger.warning(
98-
"exchange rate fetch got HTTP %s from %s",
99-
resp.status_code,
100-
"Frankfurter",
101-
)
60+
# Fetch both sources concurrently — they update independent state, so a slow
61+
# provider no longer serializes behind the other. This roughly halves the
62+
# worst case (two 15s-timeout calls: 30s -> 15s), which matters most on the
63+
# startup path where refresh() is awaited before the app serves requests.
64+
# return_exceptions so one source's network failure can't discard the other's
65+
# success (each source still tracks its own last-fetch / non-200 internally).
66+
results = await asyncio.gather(_fetch_crypto(client), _fetch_fiat(client), return_exceptions=True)
67+
for r in results:
68+
if isinstance(r, Exception):
69+
logger.error("Exchange rate fetch failed: %s", r)
10270

10371
_last_fetch = min(_crypto_last_fetch, _fiat_last_fetch)
10472
logger.info(
@@ -110,6 +78,48 @@ async def refresh() -> None:
11078
logger.error("Exchange rate fetch failed: %s", exc)
11179

11280

81+
async def _fetch_crypto(client: httpx.AsyncClient) -> None:
82+
"""Fetch crypto→USD from CoinGecko (free, no key). Updates crypto state in place."""
83+
global _crypto_usd, _crypto_last_fetch
84+
if not CRYPTO_IDS:
85+
# Nothing to fetch -- don't let an empty crypto map hold the aggregate
86+
# staleness clock back forever.
87+
_crypto_last_fetch = time.time()
88+
return
89+
ids = ",".join(CRYPTO_IDS.values())
90+
resp = await client.get(
91+
"https://api.coingecko.com/api/v3/simple/price",
92+
params={"ids": ids, "vs_currencies": "usd"},
93+
)
94+
if resp.status_code == 200:
95+
data = resp.json()
96+
for token, cg_id in CRYPTO_IDS.items():
97+
price = (data.get(cg_id) or {}).get("usd")
98+
if price is not None:
99+
_crypto_usd[token] = float(price)
100+
_crypto_last_fetch = time.time()
101+
else:
102+
logger.warning("exchange rate fetch got HTTP %s from %s", resp.status_code, "CoinGecko")
103+
104+
105+
async def _fetch_fiat(client: httpx.AsyncClient) -> None:
106+
"""Fetch USD→fiat from Frankfurter (free, no key). Updates fiat state in place."""
107+
global _fiat_rates, _fiat_last_fetch
108+
resp = await client.get(
109+
"https://api.frankfurter.app/latest",
110+
params={"from": "USD"},
111+
)
112+
if resp.status_code == 200:
113+
data = resp.json()
114+
new_rates: dict[str, float] = {"USD": 1.0}
115+
for code, rate in data.get("rates", {}).items():
116+
new_rates[code] = float(rate)
117+
_fiat_rates = new_rates
118+
_fiat_last_fetch = time.time()
119+
else:
120+
logger.warning("exchange rate fetch got HTTP %s from %s", resp.status_code, "Frankfurter")
121+
122+
113123
def rates_stale() -> bool:
114124
"""Return True if cached rates are stale (refreshes appear to be failing).
115125

app/main.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,14 @@ async def _run_health_check() -> None:
208208
status = s.get("status", "unknown")
209209
if slug_best.get(slug) != "running":
210210
slug_best[slug] = status
211+
# Collect every event for this cycle and write them in a single transaction
212+
# rather than one fsync'd commit per service (see database.record_health_events).
213+
events: list[tuple[str, str, str]] = []
211214
for slug, status in slug_best.items():
212215
if status == "running":
213-
await database.record_health_event(slug, "check_ok")
216+
events.append((slug, "check_ok", ""))
214217
else:
215-
await database.record_health_event(slug, "check_down", status)
218+
events.append((slug, "check_down", status))
216219

217220
workers = await database.list_workers()
218221
if any(w.get("status") == "online" for w in workers):
@@ -221,7 +224,9 @@ async def _run_health_check() -> None:
221224
slug = d["slug"]
222225
if d.get("status") == "external" or slug in slug_best:
223226
continue
224-
await database.record_health_event(slug, "check_down", "missing from heartbeat")
227+
events.append((slug, "check_down", "missing from heartbeat"))
228+
229+
await database.record_health_events(events)
225230
except Exception as exc:
226231
logger.warning("Health check skipped: %s", exc)
227232

tests/test_coverage_gaps.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -339,15 +339,18 @@ def test_health_check_records_events(self):
339339
mock_record = AsyncMock()
340340
with (
341341
patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers),
342-
patch("app.main.database.record_health_event", mock_record),
342+
patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[]),
343+
patch("app.main.database.record_health_events", mock_record),
343344
):
344345
asyncio.run(_run_health_check())
345346

346-
# Should record check_ok for honeygain (running) and check_down for earnapp (stopped)
347-
calls = mock_record.call_args_list
348-
slugs_events = [(c.args[0], c.args[1]) for c in calls]
349-
assert ("honeygain", "check_ok") in slugs_events
350-
assert ("earnapp", "check_down") in slugs_events
347+
# One batched write carrying check_ok for honeygain (running) and check_down for
348+
# earnapp (stopped).
349+
mock_record.assert_awaited_once()
350+
events = mock_record.call_args.args[0]
351+
pairs = [(e[0], e[1]) for e in events]
352+
assert ("honeygain", "check_ok") in pairs
353+
assert ("earnapp", "check_down") in pairs
351354

352355

353356
class TestMainStaleWorkers:

tests/test_database.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,26 @@ async def run():
427427

428428
asyncio.run(run())
429429

430+
def test_record_health_events_batched(self, db):
431+
async def run():
432+
# One batched write of several events across two services (the health-check
433+
# path uses this instead of a commit per service).
434+
await database.record_health_events(
435+
[
436+
("honeygain", "check_ok", ""),
437+
("honeygain", "restart", ""),
438+
("earnapp", "check_down", "stopped"),
439+
]
440+
)
441+
by_slug = {s["slug"]: s for s in await database.get_health_scores(7)}
442+
assert set(by_slug) == {"honeygain", "earnapp"}
443+
assert by_slug["honeygain"]["restarts"] == 1
444+
# An empty batch is a no-op — no crash, nothing written.
445+
await database.record_health_events([])
446+
assert len(await database.get_health_scores(7)) == 2
447+
448+
asyncio.run(run())
449+
430450

431451
class TestWorkerKeys:
432452
def test_workers_table_has_api_key_enc_column(self, db):

tests/test_workers.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,11 @@ def test_bad_worker_entry_does_not_abort_others(self):
390390
# ---------------------------------------------------------------------------
391391

392392

393+
def _batched_events(mock):
394+
"""The single batched event list passed to record_health_events ([] if uncalled)."""
395+
return list(mock.call_args.args[0]) if mock.call_args else []
396+
397+
393398
class TestHealthCheckVanishedService:
394399
def test_known_deployment_missing_from_heartbeat_gets_check_down(self):
395400
"""A Docker-backed deployment absent from every online worker's
@@ -402,10 +407,10 @@ def test_known_deployment_missing_from_heartbeat_gets_check_down(self):
402407
with (
403408
patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers),
404409
patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=deployments),
405-
patch("app.main.database.record_health_event", mock_record),
410+
patch("app.main.database.record_health_events", mock_record),
406411
):
407412
_run(_run_health_check())
408-
mock_record.assert_any_call("honeygain", "check_down", "missing from heartbeat")
413+
assert ("honeygain", "check_down", "missing from heartbeat") in _batched_events(mock_record)
409414

410415
def test_external_deployment_never_flagged_missing(self):
411416
"""External (no-container) deployments like Grass/Bytelixir are never
@@ -417,10 +422,10 @@ def test_external_deployment_never_flagged_missing(self):
417422
with (
418423
patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers),
419424
patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=deployments),
420-
patch("app.main.database.record_health_event", mock_record),
425+
patch("app.main.database.record_health_events", mock_record),
421426
):
422427
_run(_run_health_check())
423-
mock_record.assert_not_called()
428+
assert all(e[0] != "grass" for e in _batched_events(mock_record))
424429

425430
def test_fully_offline_fleet_does_not_flag_missing(self):
426431
"""With no worker online there is no heartbeat data to trust either
@@ -432,10 +437,10 @@ def test_fully_offline_fleet_does_not_flag_missing(self):
432437
with (
433438
patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers),
434439
patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=deployments),
435-
patch("app.main.database.record_health_event", mock_record),
440+
patch("app.main.database.record_health_events", mock_record),
436441
):
437442
_run(_run_health_check())
438-
mock_record.assert_not_called()
443+
assert _batched_events(mock_record) == []
439444

440445
def test_service_present_in_heartbeat_not_double_flagged(self):
441446
"""A service still reported by a worker must get exactly one health
@@ -453,10 +458,11 @@ def test_service_present_in_heartbeat_not_double_flagged(self):
453458
with (
454459
patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers),
455460
patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=deployments),
456-
patch("app.main.database.record_health_event", mock_record),
461+
patch("app.main.database.record_health_events", mock_record),
457462
):
458463
_run(_run_health_check())
459-
mock_record.assert_called_once_with("honeygain", "check_ok")
464+
# Exactly one event (the normal check_ok), never a second "missing" on top.
465+
assert _batched_events(mock_record) == [("honeygain", "check_ok", "")]
460466

461467

462468
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)