fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정

- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -80,12 +80,20 @@ _ACTIVE_TRADE_META_KEYS = (
# [M-A 2026-10-02] 멱등화 마커 — 워커가 처리 중에 채운다(ORM 컬럼 아님, 호출부는 넣지 않는다).
"_target_id",
"_applied_remaining_pct",
# [2026-10-08] 스윙 진입가 정합 — 추가매수 가중평균 / 체결가 보정 (ORM 컬럼 아님)
"accumulate_entry",
"entry_fill_adjust",
"_applied_entry_price",
"_applied_quantity",
"_retried_exec", # [2026-10-09] 재시도 실행 표식(경고용, ORM 컬럼 아님)
)
# 정상 경로 재시도·DLQ 저장 전에 지우는 마커. `_applied_remaining_pct` 는 "이번 실행이
# 계산한 절대값"이라, 실패 후 재시도(큐 뒤로 재등록 → 그 사이 다른 multiply 가 먼저 적용될 수
# 있음)에서 재사용하면 그 갱신을 덮어쓴다. `_target_id`(close 대상 고정)는 유지한다 —
# 재시도도 처음 고른 행을 닫는 것이 맞다.
_RETRY_STRIP_MARKERS: tuple[str, ...] = ("_applied_remaining_pct",)
_RETRY_STRIP_MARKERS: tuple[str, ...] = (
"_applied_remaining_pct", "_applied_entry_price", "_applied_quantity",
)
_PINNED_TASK_TYPES: tuple[str, ...] = ("close_active_trade", "upsert_active_trade")
@@ -728,6 +736,82 @@ async def replay_dlq() -> int:
return requeued
# [2026-10-09] 런타임 DLQ 적재/워커 적체 경보. 같은 키는 이 간격(초)에 1회만 텔레그램 발송.
DLQ_ALERT_INTERVAL_SEC = 300.0
WORKER_QUEUE_ALERT_DEPTH = 200 # qsize 가 이 값 이상이면 경보
DLQ_RUNTIME_REPLAY_INTERVAL_SEC = 300.0 # 런타임 pending DLQ 재처리 주기
CIRCUIT_OPEN_FAILURES = 5 # 연속 실패 이 횟수 이상이면 DB 장애로 간주(회로 열림)
CIRCUIT_RETRY_DELAY_SEC = 8.0
CIRCUIT_MAX_HOLD_SEC = 120.0 # 회로 열림 상태로 재시도 횟수를 아끼는 최대 시간
_alert_tasks: set = set()
# 워커 상태 스냅샷 (/health·경보용). 워커가 갱신한다.
_worker_state: dict = {"consecutive_failures": 0, "circuit_open": False, "last_success_ts": None}
async def _send_alert_impl(category: str, message: str) -> None:
try:
from app.services.telegram_notifier import telegram_notifier
await asyncio.wait_for(telegram_notifier.notify_system_warning(category, message), timeout=10)
except Exception as e:
logger.warning("[DBWorker] 텔레그램 경보 전송 실패(%s): %s", category, e)
def _alert_throttled_v2(key: str, category: str, message: str,
interval: float = DLQ_ALERT_INTERVAL_SEC) -> bool:
"""throttle 된 비차단 텔레그램 경보. 이벤트 루프가 없으면 로그만 남긴다."""
from app.core.log_throttle import should_log
ok, suppressed = should_log(key, interval)
if not ok:
return False
if suppressed:
message = f"{message} (+{suppressed}건 억제)"
try:
fut = asyncio.ensure_future(_send_alert_impl(category, message))
except RuntimeError as e:
logger.warning("[DBWorker] 경보 태스크 생성 실패: %s", e)
return False
_alert_tasks.add(fut)
fut.add_done_callback(_alert_tasks.discard)
return True
QUEUE_DEPTH_CHECK_INTERVAL_SEC = 30.0 # 성공 경로 적체 점검 주기
SLOW_TASK_ALERT_SEC = 5.0 # 단일 쓰기가 이 시간 이상 걸리면 느린 DB 로 간주
_depth_check_state: dict = {"last": 0.0}
def _check_queue_backlog_v2(queue: asyncio.Queue, task_elapsed: float | None = None,
now: float | None = None) -> bool:
"""[2026-10-09] 성공 경로 적체 점검. 쓰기가 실패 없이 느린 경우에도 qsize 경보를 낸다.
QUEUE_DEPTH_CHECK_INTERVAL_SEC 마다 1회만 평가(핫패스 비용 최소). 경보는 실패 경로와
같은 키로 throttle 되어 중복 발송되지 않는다.
"""
t = time.monotonic() if now is None else now
if t - _depth_check_state["last"] < QUEUE_DEPTH_CHECK_INTERVAL_SEC:
return False
_depth_check_state["last"] = t
depth = queue.qsize()
if depth < WORKER_QUEUE_ALERT_DEPTH:
return False
slow = f", 직전 쓰기 {task_elapsed:.1f}s" if task_elapsed is not None else ""
return _alert_throttled_v2(
"db_worker:queue_depth", "DB_WORKER",
f"DB 쓰기 큐 적체 qsize={depth} (실패 없음{slow}). DB 지연 확인 필요.",
)
def get_worker_health_v2() -> dict:
"""큐 적체·회로 상태 (동기, DB 접근 없음). /health 노출용."""
q = _db_task_queue
return {
"qsize": q.qsize() if q is not None else 0,
"consecutive_failures": _worker_state["consecutive_failures"],
"circuit_open": _worker_state["circuit_open"],
"last_success_ts": _worker_state["last_success_ts"],
}
async def _save_to_dlq(task: dict, error: str) -> None:
"""3회 재시도 실패한 task를 DLQ 파일로 저장.
@@ -760,11 +844,20 @@ async def _save_to_dlq(task: dict, error: str) -> None:
"[DLQ] Task saved to DLQ: %s | type=%s | error=%s",
filename, task_type, error,
)
_alert_throttled_v2(
"db_worker:dlq_saved", "DLQ",
f"DB 쓰기 {task_type} 가 재시도 실패로 DLQ 에 적재됨 "
f"(ActiveTrade·밴 등 DB 상태가 어긋날 수 있음). 주기 재처리가 복구를 시도한다.",
)
except Exception as e:
logger.critical(
"[DLQ] Failed to save to DLQ: %s | task_type=%s",
e, task.get("type", "unknown"),
)
_alert_throttled_v2(
"db_worker:dlq_save_failed", "DLQ",
f"DLQ 파일 저장 자체가 실패 — 태스크 유실 (type={task.get('type', 'unknown')}): {e}",
)
# ─────────────────────────────────────────────────────────────────────────────
@@ -1233,6 +1326,117 @@ async def _handle_update_trade_history_by_order_id(session, data: dict) -> None:
)
def _apply_entry_price_adjustments_v2(record, data: dict) -> frozenset:
"""[2026-10-08] 기존 active 행의 진입가·수량을 '누적 원가' 기준으로 갱신한다.
- ``accumulate_entry=True`` (추가매수): quantity = 기존 + 신규, entry_price = 수량 가중평균,
highest_since_entry = max(기존, 신규). 이전에는 신규 매수 수량·주문가로 **덮어써**
스윙 진입가가 마지막 추가매수가로 바뀌었다(스윙 손익·손절선 왜곡).
- ``entry_fill_adjust={"order_price","filled_price","filled_qty"}`` (체결 확인 후):
entry_price += (체결가 − 주문가) × 체결수량 / 행 수량. 신규 진입(행 수량 == 체결수량)이면
정확히 체결가가 된다. 추가매수였다면 그 매수분의 원가 차이만 반영된다.
둘 다 비멱등이라 계산한 절대값을 payload 에 고정한다(`_applied_*` + `_target_id`,
remaining_pct_multiply 와 같은 규약). 정상 재시도에서는 `_strip_retry_markers` 가 지운다.
Returns:
일반 setattr 루프에서 건너뛸 키 집합.
"""
skip: set = set()
_rid = getattr(record, "id", None)
_pinned = (
data.get("_applied_entry_price") is not None
and _rid is not None
and data.get("_target_id") == _rid
)
if data.get("accumulate_entry"):
# [2026-10-09] remaining_pct 도 함께 재계산한다(불변식: 실보유 = quantity × remaining_pct).
# 호출부가 넘긴 remaining_pct 는 무시한다(옛 스냅샷 값이 재계산을 덮어쓰는 것을 방지).
skip.update({"quantity", "entry_price", "highest_since_entry", "remaining_pct"})
if _pinned:
record.entry_price = float(data["_applied_entry_price"])
if data.get("_applied_quantity") is not None:
record.quantity = int(data["_applied_quantity"])
if data.get("_applied_remaining_pct") is not None:
record.remaining_pct = float(data["_applied_remaining_pct"])
else:
old_q = int(getattr(record, "quantity", 0) or 0)
old_p = float(getattr(record, "entry_price", 0) or 0)
add_q = int(data.get("quantity") or 0)
add_p = float(data.get("entry_price") or 0)
new_q = old_q + add_q
# 가중치는 '현재 보유분' = 원본 수량 × remaining_pct (분할 익절 후 추가매수 시
# 이미 판 물량의 원가가 평단에 섞이지 않게). quantity 컬럼은 원본 누적 규약 유지.
try:
_rp = float(getattr(record, "remaining_pct", 1.0) or 1.0)
except (TypeError, ValueError):
_rp = 1.0
_rp = min(1.0, max(0.0, _rp))
held_q = old_q * _rp
if add_q > 0 and add_p > 0 and held_q > 0 and old_p > 0:
new_p = (held_q * old_p + add_q * add_p) / (held_q + add_q)
elif add_q > 0 and add_p > 0:
new_p = add_p
else:
new_p = old_p
record.quantity = new_q
record.entry_price = round(float(new_p), 2) # Numeric(15,2)
# 분할 익절 뒤 추가매수: 원본 누적(quantity)이 늘었으므로 remaining_pct 를
# (현재 보유 + 추가) / (원본 + 추가) 로 맞춘다. 안 맞추면 실보유/remaining_pct 로
# 역산한 원본이 부풀어 다음 분할매도 수량이 과대(과다매도)가 된다.
_rp_changed = add_q > 0 and new_q > 0 and _rp < 1.0
if _rp_changed:
record.remaining_pct = round(min(1.0, (held_q + add_q) / new_q), 6)
if _rid is not None:
data["_applied_entry_price"] = float(record.entry_price)
data["_applied_quantity"] = int(record.quantity)
if _rp_changed:
data["_applied_remaining_pct"] = float(record.remaining_pct)
data["_target_id"] = _rid
_new_high = data.get("highest_since_entry")
if _new_high is not None:
_old_high = float(getattr(record, "highest_since_entry", 0) or 0)
record.highest_since_entry = max(_old_high, float(_new_high))
return frozenset(skip)
adj = data.get("entry_fill_adjust")
if isinstance(adj, dict):
skip.add("entry_price")
if _pinned:
record.entry_price = float(data["_applied_entry_price"])
return frozenset(skip)
try:
o = float(adj.get("order_price") or 0)
f = float(adj.get("filled_price") or 0)
q = int(adj.get("filled_qty") or 0)
except (TypeError, ValueError):
logger.warning("⚠️ [Worker] entry_fill_adjust 파싱 실패 — 진입가 보정 생략: %r", adj)
return frozenset(skip)
rec_q = int(getattr(record, "quantity", 0) or 0)
cur = float(getattr(record, "entry_price", 0) or 0)
try:
_rp2 = min(1.0, max(0.0, float(getattr(record, "remaining_pct", 1.0) or 1.0)))
except (TypeError, ValueError):
_rp2 = 1.0
# 평단은 '현재 보유분' 가중이므로 분모도 실보유(원본 × remaining_pct)로 맞춘다.
held_q2 = rec_q * _rp2
if o > 0 and f > 0 and q > 0 and held_q2 >= 1 and cur > 0:
q_eff = min(q, held_q2)
new_p = cur + (f - o) * q_eff / held_q2
if new_p > 0:
record.entry_price = round(float(new_p), 2) # Numeric(15,2)
if _rid is not None:
data["_applied_entry_price"] = float(record.entry_price)
data["_target_id"] = _rid
else:
logger.warning(
"⚠️ [Worker] ActiveTrade 체결가 보정 조건 불충족 — 생략 "
"(order=%s filled=%s qty=%s row_qty=%s row_entry=%s)", o, f, q, rec_q, cur,
)
return frozenset(skip)
async def _handle_upsert_active_trade(session, data: dict) -> None:
"""ActiveTrade UPSERT — 정상 경로와 종료 drain 이 공유하는 단일 구현."""
from app.models.models import ActiveTrade
@@ -1265,6 +1469,14 @@ async def _handle_upsert_active_trade(session, data: dict) -> None:
result = await session.execute(query)
record = result.scalars().first()
if record is None and data.get("entry_fill_adjust"):
# 체결가 보정 전용 payload(수량·진입가 없음) — 대상 행이 없으면(이미 청산 등) 할 일이 없다.
logger.info(
"ℹ️ [Worker] ActiveTrade 체결가 보정 생략: %s active 행 없음 (strategy=%s)",
stock_code, strategy_name,
)
return
if record is None:
# remaining_pct_multiply·전략명 필터 키는 신규 생성 시 무시
create_data = {
@@ -1280,6 +1492,12 @@ async def _handle_upsert_active_trade(session, data: dict) -> None:
stock_code, _missing, sorted(create_data.keys()),
)
return
if data.get("_retried_exec"):
logger.warning(
"⚠️ [Worker] ActiveTrade 신규 생성이 재시도 실행에서 발생: %s (strategy=%s) — "
"순서 역전/유령 행 가능성, KIS 잔고와 대조 필요",
stock_code, strategy_name,
)
record = ActiveTrade(**create_data)
session.add(record)
else:
@@ -1302,7 +1520,12 @@ async def _handle_upsert_active_trade(session, data: dict) -> None:
if _rid is not None:
data["_applied_remaining_pct"] = record.remaining_pct
data["_target_id"] = _rid
# [2026-10-08] 추가매수(accumulate_entry) / 체결가 보정(entry_fill_adjust) 은 진입가·수량을
# **기존 행 기준으로 계산**해야 하므로 아래 일반 setattr 루프에서 해당 키를 제외한다.
_skip_keys = _apply_entry_price_adjustments_v2(record, data)
for key, val in data.items():
if key in _skip_keys:
continue
# [C1 2026-09-28] 반드시 continue — return 이면 'stock_code' 가 모든 payload 의
# 첫 키라서 기존 active 행에 대한 모든 upsert 가 첫 이터레이션에서 빠져나가
# commit() 에 도달하지 못한다(예외가 없어 성공 처리되고 DLQ 에도 안 남는다).
@@ -1329,6 +1552,11 @@ async def _handle_upsert_stock_ban(session, data: dict) -> None:
stock_code = data.get("stock_code")
consecutive_losses = data.get("consecutive_losses")
# stock_bans.ban_until은 tz-naive 컬럼 — tz-aware 값이 들어와도 안전하게 저장.
# [시각 규약 — 의도적] ban_until 은 naive-**UTC** (loss_tracker 가 to_aware_utc 로 읽는다).
# 같은 행의 updated_at 등 다른 테이블 시각은 KST-naive 라 SQL 에서 `ban_until > localtimestamp`
# 처럼 직접 비교하면 9시간 어긋난다 -> `(ban_until AT TIME ZONE 'UTC') > now()` 를 쓸 것.
# 저장만 KST 로 바꾸면 기존 활성 밴이 9시간 지연 해제되고(load 도 같이 바꿔야 함) 마이그레이션이
# 필요하므로 규약을 유지한다(테스트로 고정: test_db_worker_stock_ban_tz_20261010).
ban_until = to_naive_utc(data.get("ban_until"))
result = await session.execute(select(StockBan).where(StockBan.stock_code == stock_code))
@@ -1441,6 +1669,11 @@ async def _handle_close_active_trade(session, data: dict) -> None:
stock_code,
strategy_name,
)
else:
logger.warning(
"⚠️ [Worker] ActiveTrade close 대상 0건: %s (strategy=%s, pinned=%s, retried=%s)",
stock_code, strategy_name, pinned_id, bool(data.get("_retried_exec")),
)
# ─────────────────────────────────────────────────────────────────────────────
@@ -1457,6 +1690,8 @@ async def _execute_db_task_impl(task: dict) -> None:
task["data"] = dict(task["data"])
task["_data_owned"] = True
data = task.get("data")
if task_type in _PINNED_TASK_TYPES and isinstance(data, dict) and task.get("_requeued"):
data["_retried_exec"] = True
async with AsyncSessionLocal() as session:
# ── 1. 매매일지 INSERT ─────────────────────────────────────
@@ -1750,6 +1985,128 @@ async def _drain_on_shutdown_impl(
return remaining
def _task_order_keys_v2(task: dict) -> frozenset:
"""[2026-10-09] 쓰기 순서 보장 키 — 같은 종목(stock_code)/주문(order_id)의 쓰기는 순서를 지킨다."""
keys = set()
data = task.get("data") if isinstance(task, dict) else None
if isinstance(data, dict):
if data.get("stock_code"):
keys.add(("s", data["stock_code"]))
if data.get("order_id"):
keys.add(("o", data["order_id"]))
return frozenset(keys)
class _OrderGateV2:
"""지연 재투입 중인 태스크와 같은 키의 뒤 태스크를 보류해 쓰기 순서를 유지한다.
재시도가 큐 꼬리로 재투입되면 그 사이 뒤 태스크(close 등)가 먼저 실행돼, 실패했던 진입
upsert 가 나중에 '행 없음 → 신규 생성' 으로 이미 청산된 포지션의 유령 active 행을 만든다.
"""
def __init__(self) -> None:
self._deferred: dict[int, frozenset] = {} # id(task) -> keys (재시도 대기)
self._released: dict[int, frozenset] = {} # id(task) -> keys (풀려났으나 아직 미완료)
self._held: list[dict] = []
def defer(self, task: dict) -> None:
task["_requeued"] = True
self._released.pop(id(task), None)
self._deferred[id(task)] = _task_order_keys_v2(task)
def should_hold(self, task: dict) -> bool:
tid = id(task)
if tid in self._deferred:
return False
keys = _task_order_keys_v2(task)
if not keys:
return False
if tid in self._released:
# 풀려난 태스크: 앞선 같은 키 태스크가 다시 재시도 대기에 들어갔다면 앞지르지 않도록 보류
blocked = any(keys & k for k in self._deferred.values())
if blocked:
self._released.pop(tid, None)
return blocked
# 재시도 대기 중이거나 풀려났으나 아직 끝나지 않은 같은 키 태스크가 있으면 그 뒤로 보류
return (any(keys & k for k in self._deferred.values())
or any(keys & k for k in self._released.values()))
def hold(self, task: dict) -> None:
self._held.append(task)
def held_tasks(self) -> list:
return list(self._held)
def resolve(self, task: dict) -> list:
"""태스크가 성공/DLQ 로 끝났을 때 호출. 이제 막힌 곳이 없는 보류 태스크를 순서대로 반환.
반환된 태스크는 실행이 끝날 때까지 '진행 중' 키로 남아, 큐 꼬리에 뒤따라 들어온
같은 키의 후속 태스크가 앞지르지 못한다.
"""
a = self._deferred.pop(id(task), None)
b = self._released.pop(id(task), None)
if a is None and b is None:
return []
released, still = [], []
for h in self._held:
hk = _task_order_keys_v2(h)
blocked = (any(hk & k for k in self._deferred.values())
or any(hk & k for k in self._released.values()))
if blocked:
still.append(h)
else:
released.append(h)
self._released[id(h)] = hk
self._held = still
return released
async def _requeue_after_v2(queue: asyncio.Queue, task: dict, delay: float, backoff: list) -> None:
"""워커를 막지 않는 지연 재투입. 취소되면 task 는 backoff 목록에 남아 종료 drain 이 처리한다."""
await asyncio.sleep(delay)
# remove + put_nowait 사이에 await 가 없어 drain 과 경합하지 않는다
if task in backoff:
backoff.remove(task)
queue.put_nowait(task)
# [R5] 실패 시 task_done() 을 미뤄 unfinished 를 유지했다 — 재투입분이 +1 이므로 원래 몫을 여기서 상쇄
queue.task_done()
def _release_held_v2(queue: asyncio.Queue, task: dict) -> None:
"""보류분 재투입. 보류 시 task_done() 을 미뤘으므로 재투입 +1 을 상쇄해 join() 이 보류 중 풀리지 않게 한다."""
queue.put_nowait(task)
queue.task_done()
async def _dlq_runtime_replay_loop_v2(queue: asyncio.Queue) -> None:
"""[2026-10-09] 런타임 DLQ 주기 재처리. 재기동 전까지 DLQ 가 방치되던 것을 막는다.
DB 가 살아 있음을 SELECT 1 로 확인하고, 회로가 열려 있거나 큐가 많이 적체되면 건너뛴다.
재큐잉 자체는 replay_dlq (큐 경유, 충돌·나이 판정 포함)에 맡긴다.
"""
from sqlalchemy import text
while True:
try:
await asyncio.wait_for(asyncio.Event().wait(), timeout=DLQ_RUNTIME_REPLAY_INTERVAL_SEC)
except asyncio.TimeoutError:
pass
try:
if _worker_state["circuit_open"] or queue.qsize() > WORKER_QUEUE_ALERT_DEPTH:
continue
counts = await dlq_status_counts()
if counts.get("pending", 0) <= 0:
continue
# DB-READ-EXCEPTION: 헬스 프로브 SELECT 1 (db_worker 소유, 쓰기 없음)
async with AsyncSessionLocal() as _s:
await _s.execute(text("SELECT 1"))
n = await replay_dlq()
logger.warning("[DLQ] 런타임 재처리: pending=%d → 재큐잉 %d건", counts["pending"], n)
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning("[DLQ] 런타임 재처리 건너뜀(DB 미복구 또는 오류): %s", e)
async def db_writer_worker() -> None:
"""lifespan 에서 asyncio.create_task() 로 실행되는 영구 백그라운드 워커.
@@ -1769,13 +2126,32 @@ async def db_writer_worker() -> None:
inflight: dict | None = None
backoff: list[dict] = []
pending_saves: set = set()
timers: set = set()
circuit_since: float | None = None
_worker_state.update(consecutive_failures=0, circuit_open=False)
gate = _OrderGateV2()
replay_task = asyncio.ensure_future(_dlq_runtime_replay_loop_v2(queue))
try:
while True:
task = await queue.get()
if gate.should_hold(task):
# 같은 종목/주문의 앞선 쓰기가 재시도 대기 중 — 순서 보장을 위해 그 뒤로 보류
# task_done() 을 미룬다(unfinished 유지) — 재투입 시 _release_held_v2 가 상쇄
gate.hold(task)
continue
inflight = task
_deferred_retry = False
try:
_t0 = time.monotonic()
await _execute_db_task_impl(task)
inflight = None
for _rel in gate.resolve(task):
_release_held_v2(queue, _rel)
_worker_state.update(
consecutive_failures=0, circuit_open=False, last_success_ts=time.time(),
)
circuit_since = None
_check_queue_backlog_v2(queue, time.monotonic() - _t0)
except asyncio.CancelledError:
raise
except Exception as exc:
@@ -1785,17 +2161,53 @@ async def db_writer_worker() -> None:
task_type = task.get("type", "unknown")
_strip_retry_markers(task) # [M-A] 실패 실행의 절대값 마커는 재사용 금지
if retry_count < max_retries:
_worker_state["consecutive_failures"] += 1
if _worker_state["consecutive_failures"] >= CIRCUIT_OPEN_FAILURES:
if circuit_since is None:
circuit_since = time.monotonic()
logger.error(
"❌ [Worker] 연속 %d회 실패 — DB 장애로 간주(회로 열림), 재시도 횟수 소모 보류",
_worker_state["consecutive_failures"],
)
_worker_state["circuit_open"] = True
_circuit_hold = (
circuit_since is not None
and time.monotonic() - circuit_since < CIRCUIT_MAX_HOLD_SEC
)
if queue.qsize() >= WORKER_QUEUE_ALERT_DEPTH:
_alert_throttled_v2(
"db_worker:queue_depth", "DB_WORKER",
f"DB 쓰기 큐 적체 qsize={queue.qsize()} (연속 실패 "
f"{_worker_state['consecutive_failures']}회). DB 상태 확인 필요.",
)
if _circuit_hold:
# 장애 구간: 재시도 횟수를 소모하지 않고 일괄 대기 후 재투입(DLQ 조기 유실 방지)
delay = CIRCUIT_RETRY_DELAY_SEC
logger.warning(
"⚠️ [Worker] 회로 열림 — type=%s 재시도 횟수 유지(%d), %.0fs 후 재투입: %s",
task_type, retry_count, delay, exc,
)
gate.defer(task)
_deferred_retry = True
_t = asyncio.ensure_future(_requeue_after_v2(queue, task, delay, backoff))
backoff.append(task)
timers.add(_t)
_t.add_done_callback(timers.discard)
elif retry_count < max_retries:
task["_retry_count"] = retry_count + 1
delay = min(2 ** retry_count, 8) # 1, 2, 4초 지수 백오프
logger.warning(
"⚠️ [Worker] DB task retry %d/%d (type=%s): %s — %ds 후 재시도",
retry_count + 1, max_retries, task_type, exc, delay,
)
backoff.append(task) # 백오프 중 cancel 되면 drain 이 이 목록을 먼저 처리
await asyncio.sleep(delay)
backoff.remove(task)
await queue.put(task) # 큐에 재등록
# 워커를 막지 않고 지연 재투입 — 그 사이 다른 태스크 계속 처리.
# backoff 목록: 취소 시 drain 이 이 목록을 먼저 처리
backoff.append(task)
gate.defer(task)
_deferred_retry = True
_t = asyncio.ensure_future(_requeue_after_v2(queue, task, delay, backoff))
timers.add(_t)
_t.add_done_callback(timers.discard)
else:
logger.error(
"❌ [Worker] DB task DROPPED after %d retries (type=%s): %s",
@@ -1807,13 +2219,20 @@ async def db_writer_worker() -> None:
pending_saves.add(fut)
fut.add_done_callback(pending_saves.discard)
await asyncio.shield(fut)
for _rel in gate.resolve(task):
_release_held_v2(queue, _rel)
finally:
queue.task_done()
if not _deferred_retry:
queue.task_done()
except asyncio.CancelledError:
# 큐 drain: 종료 전 남은 태스크 처리하여 데이터 손실 방지
for _t in list(timers):
_t.cancel()
replay_task.cancel()
_drain_started = time.monotonic()
remaining = await _drain_on_shutdown_impl(
queue, inflight, backoff, pending_saves, drain_started=_drain_started
queue, inflight, backoff + [h for h in gate.held_tasks() if h not in backoff],
pending_saves, drain_started=_drain_started,
)
# [M-D] 이후 투입은 DLQ 직행. drain 과 표식 사이에 들어온 건은 아래에서 한 번 더 비운다.
setattr(queue, _QUEUE_CLOSED_ATTR, True)
@@ -1824,3 +2243,5 @@ async def db_writer_worker() -> None:
if remaining:
logger.info(f"[DBWorker] 종료 시 {remaining}건 drain 완료")
logger.info("⏹️ DB Writer Worker stopped")
finally:
replay_task.cancel()