Skip to content

Commit e2747f5

Browse files
jdrumgooleclaude
andcommitted
v0.5.5: fix event-loop starvation under load; +115 lines coverage
Converted 14 FastAPI endpoints from `async def` to plain `def` so FastAPI runs them in a threadpool. A single slow yfinance call was freezing uvicorn's event loop, queuing every concurrent request — including the HTML page — and causing 30s page.goto timeouts under parallel test load. Same freeze would hit real users during Yahoo rate-limit events. `upload_transactions` stays async (needs `await file.read()`); `startup_event` stays async (FastAPI requirement). Also: main.py coverage 72% → 89% via 13 new targeted unit tests (upload pipeline, exchange-rate fallback, update-market-data ticker resolver and Yahoo fallback, refresh-live-prices error branches, purge/shutdown error paths, ensure_indices_exist rollback). Playwright `page.goto` switched to `wait_until="domcontentloaded"`. Suite now passes 5/5 consecutive parallel runs (224 tests). FastAPI app.version bumped from stale "0.4.0" to track pyproject.toml. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d696832 commit e2747f5

9 files changed

Lines changed: 504 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.5.5] - 2026-04-14
9+
10+
### Fixed
11+
- **Event-loop starvation under load (production bug)**: 14 FastAPI endpoints were declared `async def` while doing sync blocking I/O (yfinance HTTP calls, SQLAlchemy queries). A single slow upstream call froze uvicorn's event loop, queuing every concurrent request — including the HTML page — until it completed. Converted all non-awaiting endpoints to plain `def` so FastAPI runs them in a threadpool. Eliminates 30s `page.goto` timeouts seen under parallel test load and prevents the same freeze under real user load with slow upstream APIs.
12+
- **FastAPI `app.version`**: was stuck at `"0.4.0"`, now tracks `pyproject.toml` version.
13+
14+
### Testing
15+
- **Coverage for `main.py`: 72% → 89%** (+115 lines). Added 13 targeted unit tests covering upload pipeline (new-stock insert, duplicate skip, held-stock filtering, FMP live-price branch, index update exceptions), exchange-rate Yahoo fallback paths, `update-market-data` ticker auto-resolve and Yahoo fallback, `refresh-live-prices` error branches, purge/shutdown error paths, and `ensure_indices_exist` rollback.
16+
- **Playwright fixture flakiness eliminated**: switched `page.goto` to `wait_until="domcontentloaded"` in shared_page, CSV, and Dutch e2e fixtures. Combined with the event-loop fix, the full suite now passes 5/5 consecutive parallel runs (was 2–3/5 before).
17+
- **224 tests total**, stable across 5 consecutive parallel runs.
18+
819
## [0.5.4] - 2026-04-10
920

1021
### Fixed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "degiro_portfolio"
3-
version = "0.5.4"
3+
version = "0.5.5"
44
description = "DEGIRO portfolio tracking and visualization application"
55
readme = "README.md"
66
requires-python = ">=3.10"

src/degiro_portfolio/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
"""Stock price visualization application."""
2-
__version__ = "0.5.4"
2+
__version__ = "0.5.5"

src/degiro_portfolio/main.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def ensure_indices_exist(db: Session) -> tuple[int, int]:
8484
return indices_created, prices_fetched
8585

8686

87-
app = FastAPI(title="DEGIRO Portfolio", version="0.4.0")
87+
app = FastAPI(title="DEGIRO Portfolio", version="0.5.5")
8888

8989
# Track server start time
9090
SERVER_START_TIME = datetime.now()
@@ -97,7 +97,7 @@ async def startup_event():
9797

9898

9999
@app.get("/api/ping")
100-
async def ping():
100+
def ping():
101101
"""Health check endpoint returning server name and uptime."""
102102
uptime_seconds = (datetime.now() - SERVER_START_TIME).total_seconds()
103103

@@ -168,14 +168,14 @@ def to_dict(self):
168168

169169

170170
@app.get("/")
171-
async def root():
171+
def root():
172172
"""Serve the main page."""
173173
index_path = os.path.join(os.path.dirname(__file__), "static", "index.html")
174174
return FileResponse(index_path)
175175

176176

177177
@app.get("/api/holdings")
178-
async def get_holdings(db: Session = Depends(get_db)):
178+
def get_holdings(db: Session = Depends(get_db)):
179179
"""Get all current stock holdings.
180180
181181
Optimized to use bulk queries instead of N+1 pattern.
@@ -283,7 +283,7 @@ async def get_holdings(db: Session = Depends(get_db)):
283283

284284

285285
@app.get("/api/market-data-status")
286-
async def get_market_data_status(db: Session = Depends(get_db)):
286+
def get_market_data_status(db: Session = Depends(get_db)):
287287
"""Get the most recent market data date."""
288288
# Get most recent price date across all stocks
289289
latest_price = db.query(StockPrice).order_by(StockPrice.date.desc()).first()
@@ -301,7 +301,7 @@ async def get_market_data_status(db: Session = Depends(get_db)):
301301

302302

303303
@app.get("/api/exchange-rates")
304-
async def get_exchange_rates(db: Session = Depends(get_db)):
304+
def get_exchange_rates(db: Session = Depends(get_db)):
305305
"""Get current exchange rates for currency conversion.
306306
307307
Uses cached rates from the database if available for today.
@@ -384,7 +384,7 @@ def _get_fallback_rate(currency: str) -> float:
384384

385385

386386
@app.get("/api/stock/{stock_id}/prices")
387-
async def get_stock_prices(stock_id: int, db: Session = Depends(get_db)):
387+
def get_stock_prices(stock_id: int, db: Session = Depends(get_db)):
388388
"""Get historical prices for a stock."""
389389
stock = db.query(Stock).filter(Stock.id == stock_id).first()
390390
if not stock:
@@ -415,7 +415,7 @@ async def get_stock_prices(stock_id: int, db: Session = Depends(get_db)):
415415

416416

417417
@app.get("/api/stock/{stock_id}/transactions")
418-
async def get_stock_transactions(stock_id: int, db: Session = Depends(get_db)):
418+
def get_stock_transactions(stock_id: int, db: Session = Depends(get_db)):
419419
"""Get transaction history for a stock."""
420420
stock = db.query(Stock).filter(Stock.id == stock_id).first()
421421
if not stock:
@@ -447,7 +447,7 @@ async def get_stock_transactions(stock_id: int, db: Session = Depends(get_db)):
447447

448448

449449
@app.get("/api/stock/{stock_id}/chart-data")
450-
async def get_chart_data(stock_id: int, db: Session = Depends(get_db)):
450+
def get_chart_data(stock_id: int, db: Session = Depends(get_db)):
451451
"""Get chart data for a stock including prices and transactions."""
452452
stock = db.query(Stock).filter(Stock.id == stock_id).first()
453453
if not stock:
@@ -616,7 +616,7 @@ async def get_chart_data(stock_id: int, db: Session = Depends(get_db)):
616616

617617

618618
@app.get("/api/portfolio-summary")
619-
async def get_portfolio_summary(db: Session = Depends(get_db)):
619+
def get_portfolio_summary(db: Session = Depends(get_db)):
620620
"""Compute portfolio summary in a single server-side call.
621621
622622
Returns total holdings count, net invested, current value, and gain/loss.
@@ -698,7 +698,7 @@ async def get_portfolio_summary(db: Session = Depends(get_db)):
698698

699699

700700
@app.get("/api/portfolio-performance")
701-
async def get_portfolio_performance(db: Session = Depends(get_db)):
701+
def get_portfolio_performance(db: Session = Depends(get_db)):
702702
"""Get percentage return performance for all currently held stocks.
703703
704704
Optimized to fetch all data in bulk queries.
@@ -776,7 +776,7 @@ async def get_portfolio_performance(db: Session = Depends(get_db)):
776776

777777

778778
@app.get("/api/portfolio-valuation-history")
779-
async def get_portfolio_valuation_history(db: Session = Depends(get_db)):
779+
def get_portfolio_valuation_history(db: Session = Depends(get_db)):
780780
"""
781781
Get historical portfolio valuation over time.
782782
Returns dates, net invested capital (buys - sells), and portfolio values (all in EUR).
@@ -1180,7 +1180,7 @@ def determine_native_currency(df_data, product):
11801180

11811181

11821182
@app.post("/api/refresh-live-prices")
1183-
async def refresh_live_prices(db: Session = Depends(get_db)):
1183+
def refresh_live_prices(db: Session = Depends(get_db)):
11841184
"""Fetch real-time price quotes for currently held stocks using Twelve Data or Yahoo Finance.
11851185
11861186
Optimized to use bulk query for holdings calculation.
@@ -1303,7 +1303,7 @@ async def refresh_live_prices(db: Session = Depends(get_db)):
13031303

13041304

13051305
@app.post("/api/update-market-data")
1306-
async def update_market_data(db: Session = Depends(get_db)):
1306+
def update_market_data(db: Session = Depends(get_db)):
13071307
"""Fetch latest market data for currently held stocks and indices.
13081308
13091309
Optimized to use bulk query for holdings calculation.
@@ -1459,7 +1459,7 @@ async def update_market_data(db: Session = Depends(get_db)):
14591459

14601460

14611461
@app.post("/api/purge-database")
1462-
async def purge_database(db: Session = Depends(get_db)):
1462+
def purge_database(db: Session = Depends(get_db)):
14631463
"""Purge all data from the database (stocks, transactions, prices, indices).
14641464
14651465
WARNING: This is a destructive operation that cannot be undone!
@@ -1512,7 +1512,7 @@ async def purge_database(db: Session = Depends(get_db)):
15121512

15131513

15141514
@app.post("/api/shutdown")
1515-
async def shutdown():
1515+
def shutdown():
15161516
"""Shut down the server (used by desktop mode when the window closes)."""
15171517
import threading as _threading
15181518
if _shutdown_event is not None:

tests/conftest.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,11 @@ def base_url(server_process):
409409
def shared_page(context: BrowserContext, server_process):
410410
"""Create a page shared across tests in a module (for read-only tests)."""
411411
page = context.new_page()
412-
page.goto(server_process, timeout=30000)
412+
# wait_until='domcontentloaded' avoids blocking on background fetch() calls
413+
# (e.g. /api/exchange-rates, /api/portfolio-valuation-history) that the
414+
# frontend fires on load. Those can take seconds against the real subprocess
415+
# server under parallel load — we only need the DOM ready to start asserting.
416+
page.goto(server_process, timeout=30000, wait_until="domcontentloaded")
413417
yield page
414418
page.close()
415419

@@ -418,9 +422,9 @@ def shared_page(context: BrowserContext, server_process):
418422
def page(shared_page, server_process):
419423
"""Provide page for each test, navigating back to home to reset state."""
420424
# Navigate back to home page to reset state between tests
421-
shared_page.goto(server_process, timeout=30000)
425+
shared_page.goto(server_process, timeout=30000, wait_until="domcontentloaded")
422426
# Wait for page to be fully loaded (holdings to appear)
423-
shared_page.wait_for_selector(".stock-card", timeout=8000) # 8 seconds
427+
shared_page.wait_for_selector(".stock-card", timeout=15000)
424428
yield shared_page
425429

426430

tests/test_e2e_csv_import.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def mock_api_response(route):
176176
def csv_page(csv_context, csv_server):
177177
"""Page navigated to the server after CSV has been uploaded."""
178178
page = csv_context.new_page()
179-
page.goto(csv_server, timeout=10000)
179+
page.goto(csv_server, timeout=30000, wait_until="domcontentloaded")
180180
page.wait_for_selector(".stock-card", timeout=15000)
181181
yield page
182182
page.close()

tests/test_e2e_dutch_import.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def mock_api(route):
123123
@pytest.fixture(scope="module")
124124
def dutch_page(dutch_context, dutch_server):
125125
page = dutch_context.new_page()
126-
page.goto(dutch_server, timeout=15000)
126+
page.goto(dutch_server, timeout=30000, wait_until="domcontentloaded")
127127
page.wait_for_selector(".stock-card", timeout=15000)
128128
yield page
129129
page.close()

0 commit comments

Comments
 (0)