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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user