fix: dashboard research-DB connection self-heals — the 'TradingView failing' root cause
The charts weren't broken — the data pipe was empty. dashboard_api opened the research connection ONCE at boot; if the DB was unreachable then (container started before Postgres, or a blip) it stayed None forever, so /api/chart and /api/bars returned 'no research db attached' and every candlestick + sparkline rendered blank. Live-verified: GET /api/chart returned bars:[] reason:'no research db attached' on the deployed dashboard. Now DashboardState._research() opens the connection LAZILY and reopens if None — same self-heal as the event-store emit fix. 2 tests; chart/backtest endpoint tests still green. No library change: Lightweight Charts v5.2 is fine, it just had nothing to draw. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5f04d5584a
commit
a00f967bdf
2 files changed
+75
-6
No files matched your search
+34
-6
@@ -63,6 +63,12 @@ from storage.projections import (
|
||||
query_watch_states_snapshot,
|
||||
)
|
||||
|
||||
import logging
|
||||
|
||||
from research.multi_schema import open_research_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
@@ -250,6 +256,26 @@ class DashboardState:
|
||||
self.research_con = research_con
|
||||
self.watch_inject = watch_inject
|
||||
self._lock = threading.Lock()
|
||||
self._research_lock = threading.Lock()
|
||||
|
||||
def _research(self):
|
||||
"""The research DB connection, opened LAZILY and self-healing. It used
|
||||
to be opened once at boot; if the DB was unreachable then (container
|
||||
started before Postgres, or a blip), it stayed None forever and every
|
||||
chart/sparkline/backtest returned 'no research db attached' — which
|
||||
read as broken TradingView charts when the real cause was an empty data
|
||||
pipe. Now a None connection is (re)opened on demand; the facade itself
|
||||
auto-reconnects a dropped-but-open one."""
|
||||
if self.research_con is not None:
|
||||
return self.research_con
|
||||
with self._research_lock:
|
||||
if self.research_con is None:
|
||||
try:
|
||||
self.research_con = open_research_db("market")
|
||||
except Exception as exc: # noqa: BLE001 — degrade to None, retry next call
|
||||
logger.warning("research DB open failed (charts empty): %s", exc)
|
||||
self.research_con = None
|
||||
return self.research_con
|
||||
# lazily-built, process-wide SSE change signal (shared by all clients)
|
||||
self._change_signal: _ChangeSignal | None = None
|
||||
# backtest replay cache (plan 015) — a DEDICATED lock, not self._lock,
|
||||
@@ -355,22 +381,23 @@ class DashboardState:
|
||||
pipeline). If today has no bars yet (pre-market, weekend, or replay of
|
||||
an older session), falls back to the most recent session that does.
|
||||
"""
|
||||
if self.research_con is None:
|
||||
con = self._research()
|
||||
if con is None:
|
||||
return {"symbol": symbol, "bars": [], "reason": "no research db attached"}
|
||||
from research.query import query_minute_bars
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
session_date = _eastern_session_date()
|
||||
df = query_minute_bars(self.research_con, symbol, session_date)
|
||||
df = query_minute_bars(con, symbol, session_date)
|
||||
if df is None or df.empty:
|
||||
# fall back to the latest session with bars for this symbol
|
||||
row = self.research_con.execute(
|
||||
row = con.execute(
|
||||
"SELECT MAX(session_date) FROM minute_bars WHERE symbol = ?",
|
||||
[symbol],
|
||||
).fetchone()
|
||||
if row and row[0] is not None:
|
||||
df = query_minute_bars(self.research_con, symbol, row[0])
|
||||
df = query_minute_bars(con, symbol, row[0])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"symbol": symbol, "bars": [], "reason": str(exc)}
|
||||
if df is None or df.empty:
|
||||
@@ -403,7 +430,8 @@ class DashboardState:
|
||||
arbitrary past sessions the plain ``hours``-back-from-now window can
|
||||
never reach (it's capped at 72h).
|
||||
"""
|
||||
if self.research_con is None:
|
||||
con = self._research()
|
||||
if con is None:
|
||||
return {"symbol": symbol, "bars": [], "reason": "no research db attached"}
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -421,7 +449,7 @@ class DashboardState:
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
rows = self.research_con.execute(
|
||||
rows = con.execute(
|
||||
"SELECT timestamp, open, high, low, close, volume "
|
||||
"FROM minute_bars WHERE symbol = ? AND timestamp >= ? "
|
||||
"ORDER BY timestamp",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""DashboardState research-DB connection must self-heal (2026-07-20).
|
||||
|
||||
The dashboard opened its research connection ONCE at boot; if the DB was
|
||||
unreachable then (container started before Postgres, or a blip), it stayed
|
||||
None forever and every chart/sparkline/backtest returned 'no research db
|
||||
attached' — which read as 'the TradingView charts are broken' when the real
|
||||
cause was an empty data pipe. _research() reopens lazily on demand."""
|
||||
|
||||
import api.main as m
|
||||
|
||||
|
||||
def test_research_con_reopens_when_none(monkeypatch):
|
||||
opened = {"n": 0}
|
||||
|
||||
class FakeCon:
|
||||
def execute(self, *a, **k):
|
||||
class R:
|
||||
def fetchall(self_): # noqa: N805
|
||||
return []
|
||||
def df(self_): # noqa: N805
|
||||
import pandas as pd
|
||||
return pd.DataFrame()
|
||||
return R()
|
||||
|
||||
monkeypatch.setattr(m, "open_research_db", lambda name: (opened.__setitem__("n", opened["n"] + 1) or FakeCon()))
|
||||
st = m.DashboardState("momentum", research_con=None) # boot-time open FAILED -> None
|
||||
c1 = st._research()
|
||||
assert c1 is not None and opened["n"] == 1 # lazily opened
|
||||
c2 = st._research()
|
||||
assert c2 is c1 and opened["n"] == 1 # cached, not reopened each call
|
||||
|
||||
|
||||
def test_research_none_when_open_keeps_failing(monkeypatch):
|
||||
def boom(name):
|
||||
raise RuntimeError("db down")
|
||||
monkeypatch.setattr(m, "open_research_db", boom)
|
||||
st = m.DashboardState("momentum", research_con=None)
|
||||
assert st._research() is None # degrades cleanly, no raise
|
||||
# and bars() still returns the graceful shape, not an exception
|
||||
out = st.bars("AAPL")
|
||||
assert out["bars"] == [] and "reason" in out
|
||||
Reference in new issue
Block a user