"""2026-10-09 db_worker 하드닝 회귀: DLQ 경보, 비차단 백오프/회로, 추가매수 remaining_pct 불변식.""" import asyncio from types import SimpleNamespace import pytest import app.services.db_worker as dbw from app.core import log_throttle def _run(coro): return asyncio.run(coro) @pytest.fixture(autouse=True) def _iso(monkeypatch, tmp_path): log_throttle.reset_for_tests() monkeypatch.setattr(dbw, "DLQ_DIR", tmp_path / "dlq") monkeypatch.setattr(dbw, "_db_task_queue", None) yield log_throttle.reset_for_tests() # ── 1. DLQ 텔레그램 ────────────────────────────────────────────────────────── def test_save_to_dlq_sends_throttled_telegram(monkeypatch): sent = [] async def fake_send(category, message): sent.append((category, message)) monkeypatch.setattr(dbw, "_send_alert_impl", fake_send) async def go(): await dbw._save_to_dlq({"type": "close_active_trade", "data": {}}, "boom") await dbw._save_to_dlq({"type": "close_active_trade", "data": {}}, "boom") await asyncio.sleep(0.05) _run(go()) assert len(sent) == 1 and sent[0][0] == "DLQ" # throttle assert len(list(dbw.DLQ_DIR.glob("*.json"))) == 2 def test_dlq_write_failure_alerts(monkeypatch): sent = [] async def fake_send(category, message): sent.append(message) monkeypatch.setattr(dbw, "_send_alert_impl", fake_send) def bad(*a, **k): raise OSError("disk full") monkeypatch.setattr(dbw, "DLQ_DIR", type("P", (), {"mkdir": bad, "__truediv__": bad})()) async def go(): await dbw._save_to_dlq({"type": "upsert_stock_ban", "data": {}}, "x") await asyncio.sleep(0.05) _run(go()) assert len(sent) == 1 and "유실" in sent[0] # ── 2. 비차단 백오프 ───────────────────────────────────────────────────────── def test_failed_task_does_not_block_following_tasks(monkeypatch): done = [] async def fake_exec(task): if task["type"] == "bad": raise RuntimeError("db down") done.append(task["type"]) monkeypatch.setattr(dbw, "_execute_db_task_impl", fake_exec) async def fake_dlq(task, err): return None monkeypatch.setattr(dbw, "_save_to_dlq", fake_dlq) monkeypatch.setattr(dbw, "CIRCUIT_OPEN_FAILURES", 999) async def go(): q = dbw.get_db_task_queue() w = asyncio.ensure_future(dbw.db_writer_worker()) await q.put({"type": "bad", "data": {}}) await q.put({"type": "good1", "data": {}}) await q.put({"type": "good2", "data": {}}) await asyncio.sleep(0.2) # 백오프 최소 1초 — 그 안에 정상 태스크는 처리돼야 한다 snapshot = list(done) w.cancel() try: await w except asyncio.CancelledError: pass return snapshot assert _run(go()) == ["good1", "good2"] def test_circuit_open_keeps_retry_count(monkeypatch): async def fake_exec(task): raise RuntimeError("db down") monkeypatch.setattr(dbw, "_execute_db_task_impl", fake_exec) monkeypatch.setattr(dbw, "CIRCUIT_OPEN_FAILURES", 2) monkeypatch.setattr(dbw, "CIRCUIT_RETRY_DELAY_SEC", 0.01) dropped = [] async def fake_dlq(task, err): dropped.append(task) monkeypatch.setattr(dbw, "_save_to_dlq", fake_dlq) async def fake_one(task, started): # 종료 drain 은 별개 — 여기서는 워커 루프 중 DLQ 만 본다 return None monkeypatch.setattr(dbw, "_drain_one_impl", fake_one) async def go(): q = dbw.get_db_task_queue() w = asyncio.ensure_future(dbw.db_writer_worker()) t = {"type": "x", "data": {}} await q.put(t) await q.put({"type": "y", "data": {}}) # 연속 실패 2회 → 회로 열림 await asyncio.sleep(0.3) rc = t.get("_retry_count", 0) state = dbw.get_worker_health_v2() w.cancel() try: await w except asyncio.CancelledError: pass return rc, state rc, state = _run(go()) assert dropped == [] # 장애 구간에서 DLQ 로 조기 유실되지 않는다 assert rc <= 1 # 회로 열림 전 1회만 소모 assert state["circuit_open"] is True def test_cancel_during_backoff_drains_task(monkeypatch): drained = [] async def fake_exec(task): raise RuntimeError("once") monkeypatch.setattr(dbw, "_execute_db_task_impl", fake_exec) monkeypatch.setattr(dbw, "CIRCUIT_OPEN_FAILURES", 999) async def fake_one(task, started): drained.append("drain:" + task["type"]) monkeypatch.setattr(dbw, "_drain_one_impl", fake_one) async def go(): q = dbw.get_db_task_queue() w = asyncio.ensure_future(dbw.db_writer_worker()) await q.put({"type": "t", "data": {}}) await asyncio.sleep(0.1) # 백오프(1s) 대기 중 w.cancel() try: await w except asyncio.CancelledError: pass _run(go()) assert drained == ["drain:t"] # ── 3. 추가매수 remaining_pct 불변식 ───────────────────────────────────────── def _rec(**kw): base = dict(id=7, quantity=100, entry_price=10000.0, highest_since_entry=10500.0, remaining_pct=0.5) base.update(kw) return SimpleNamespace(**base) def test_accumulate_after_partial_exit_keeps_held_invariant(): r = _rec() # 원본 100, 실보유 50 data = {"accumulate_entry": True, "quantity": 50, "entry_price": 8000.0} dbw._apply_entry_price_adjustments_v2(r, data) assert r.quantity == 150 assert r.quantity * r.remaining_pct == pytest.approx(100.0, abs=0.01) # 실보유 100 r2 = _rec() # 재실행(종료 drain)도 같은 값 dbw._apply_entry_price_adjustments_v2(r2, data) assert (r2.quantity, r2.remaining_pct) == (r.quantity, r.remaining_pct) def test_accumulate_without_prior_exit_leaves_remaining_pct(): r = _rec(remaining_pct=1.0) dbw._apply_entry_price_adjustments_v2(r, {"accumulate_entry": True, "quantity": 10, "entry_price": 9000.0}) assert r.remaining_pct == 1.0 and r.quantity == 110 def test_accumulate_ignores_caller_remaining_pct(): r = _rec() skip = dbw._apply_entry_price_adjustments_v2( r, {"accumulate_entry": True, "quantity": 50, "entry_price": 8000.0, "remaining_pct": 1.0}) assert "remaining_pct" in skip def test_fill_adjust_uses_held_quantity_as_denominator(): # 실보유 100(원본150×2/3), 추가매수 50주가 주문가보다 100원 싸게 체결 r = _rec(quantity=150, remaining_pct=100 / 150, entry_price=9000.0) d = {"entry_fill_adjust": {"order_price": 8000.0, "filled_price": 7900.0, "filled_qty": 50}} dbw._apply_entry_price_adjustments_v2(r, d) assert r.entry_price == pytest.approx(9000.0 - 100 * 50 / 100, abs=0.01)