Skip to content

Commit 8c848f3

Browse files
Pigbibicodex
andcommitted
fix: recycle workers after dry-run deadline
Co-Authored-By: Codex <noreply@openai.com>
1 parent 5aecac4 commit 8c848f3

4 files changed

Lines changed: 141 additions & 71 deletions

File tree

application/runtime_deadline.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,20 @@
33
from __future__ import annotations
44

55
import contextlib
6+
import os
67
import signal
78
import threading
9+
import time
810
from collections.abc import Iterator
11+
from collections.abc import Callable
912

1013

11-
class RuntimeDeadlineExceeded(TimeoutError):
12-
"""Raised when a synchronous runtime operation exceeds its local budget."""
14+
class RuntimeDeadlineExceeded(BaseException):
15+
"""Raised when a synchronous runtime operation exceeds its local budget.
16+
17+
This intentionally bypasses broad ``except Exception`` handlers in broker and
18+
strategy code so an interrupted worker cannot resume with partial session state.
19+
"""
1320

1421

1522
@contextlib.contextmanager
@@ -46,3 +53,25 @@ def _raise_deadline(_signum, _frame) -> None:
4653
finally:
4754
signal.setitimer(signal.ITIMER_REAL, 0)
4855
signal.signal(signal.SIGALRM, previous_handler)
56+
57+
58+
def recycle_current_process_after_response(
59+
*,
60+
delay_seconds: float = 1.0,
61+
process_id: int | None = None,
62+
kill_fn: Callable[[int, int], None] = os.kill,
63+
) -> threading.Thread:
64+
"""Ask the process manager to replace a worker after its response can flush."""
65+
target_pid = int(process_id or os.getpid())
66+
67+
def _terminate() -> None:
68+
time.sleep(max(0.0, float(delay_seconds)))
69+
kill_fn(target_pid, signal.SIGTERM)
70+
71+
thread = threading.Thread(
72+
target=_terminate,
73+
name="runtime-deadline-worker-recycle",
74+
daemon=True,
75+
)
76+
thread.start()
77+
return thread

main.py

Lines changed: 60 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121
from application.cycle_result import coerce_strategy_cycle_result
2222
from application.runtime_broker_adapters import build_runtime_broker_adapters
2323
from application.runtime_composer import build_runtime_composer
24-
from application.runtime_deadline import RuntimeDeadlineExceeded, enforce_runtime_deadline
24+
from application.runtime_deadline import (
25+
RuntimeDeadlineExceeded,
26+
enforce_runtime_deadline,
27+
recycle_current_process_after_response,
28+
)
2529
from application.runtime_strategy_adapters import (
2630
build_runtime_strategy_adapters,
2731
fetch_yfinance_historical_candles,
@@ -1113,6 +1117,7 @@ def _handle_request(
11131117
)
11141118
execution_window = "dry_run" if dry_run_only_override else "execution"
11151119
lock_acquired = STRATEGY_RUN_LOCK.acquire(blocking=False)
1120+
deadline_exceeded = False
11161121
try:
11171122
log_runtime_event(
11181123
log_context,
@@ -1200,18 +1205,14 @@ def _handle_request(
12001205
if dry_run_only_override is None:
12011206
publish_strategy_plugin_alerts(strategy_plugin_signals, report=report)
12021207
notification_delivery_events: list[dict] = []
1203-
with enforce_runtime_deadline(
1204-
IBKR_DRY_RUN_DEADLINE_SECONDS if dry_run_only_override is True else 0,
1205-
operation="IBKR strategy dry-run",
1206-
):
1207-
cycle_result = coerce_strategy_cycle_result(
1208-
run_strategy_core(
1209-
strategy_plugin_signals=strategy_plugin_signals,
1210-
strategy_plugin_error=strategy_plugin_error,
1211-
dry_run_only_override=dry_run_only_override,
1212-
notification_delivery_events=notification_delivery_events,
1213-
)
1208+
cycle_result = coerce_strategy_cycle_result(
1209+
run_strategy_core(
1210+
strategy_plugin_signals=strategy_plugin_signals,
1211+
strategy_plugin_error=strategy_plugin_error,
1212+
dry_run_only_override=dry_run_only_override,
1213+
notification_delivery_events=notification_delivery_events,
12141214
)
1215+
)
12151216
execution_summary = dict(cycle_result.execution_summary or {})
12161217
reconciliation_record = dict(cycle_result.reconciliation_record or {})
12171218
signal_metadata = dict(cycle_result.signal_metadata or {})
@@ -1283,30 +1284,9 @@ def _handle_request(
12831284
result=cycle_result.result,
12841285
)
12851286
return (response_body if dry_run_only_override else cycle_result.result), 200
1286-
except RuntimeDeadlineExceeded as exc:
1287-
append_runtime_report_error(
1288-
report,
1289-
stage="strategy_deadline",
1290-
message=str(exc),
1291-
error_type=type(exc).__name__,
1292-
)
1293-
finalize_runtime_report(report, status="error")
1294-
log_runtime_event(
1295-
log_context,
1296-
"strategy_cycle_deadline_exceeded",
1297-
message="Strategy dry-run exceeded its application deadline",
1298-
severity="ERROR",
1299-
execution_window=execution_window,
1300-
timeout_seconds=IBKR_DRY_RUN_DEADLINE_SECONDS,
1301-
error_message=str(exc),
1302-
)
1303-
error_msg = f"🚨 【IBKR 运行超时】\n{str(exc)}"
1304-
_publish_runtime_failure_notification(
1305-
detailed_text=error_msg,
1306-
compact_text=error_msg,
1307-
exc=exc,
1308-
)
1309-
return "Error", 503
1287+
except RuntimeDeadlineExceeded:
1288+
deadline_exceeded = True
1289+
raise
13101290
except TimeoutError as exc:
13111291
append_runtime_report_error(
13121292
report,
@@ -1356,14 +1336,15 @@ def _handle_request(
13561336
finally:
13571337
if lock_acquired:
13581338
STRATEGY_RUN_LOCK.release()
1359-
try:
1360-
if dry_run_only_override is None:
1361-
report_path = persist_execution_report(report)
1362-
else:
1363-
report_path = persist_execution_report(report, dry_run_only_override=dry_run_only_override)
1364-
print(f"execution_report {report_path}", flush=True)
1365-
except Exception as persist_exc:
1366-
print(f"failed to persist execution report: {persist_exc}", flush=True)
1339+
if not deadline_exceeded:
1340+
try:
1341+
if dry_run_only_override is None:
1342+
report_path = persist_execution_report(report)
1343+
else:
1344+
report_path = persist_execution_report(report, dry_run_only_override=dry_run_only_override)
1345+
print(f"execution_report {report_path}", flush=True)
1346+
except Exception as persist_exc:
1347+
print(f"failed to persist execution report: {persist_exc}", flush=True)
13671348

13681349

13691350
def _handle_probe(*, response_body: str = "Probe OK"):
@@ -1507,11 +1488,7 @@ def _dispatch_local_monitor(dispatch):
15071488
if window == "probe":
15081489
_body, status_code = _handle_probe()
15091490
elif window == "precheck":
1510-
_body, status_code = _handle_request(
1511-
dry_run_only_override=True,
1512-
response_body="Dry Run OK",
1513-
dry_run_label="strategy dry-run",
1514-
)
1491+
_body, status_code = _handle_dry_run_with_deadline()
15151492
else:
15161493
raise ValueError(f"Unsupported local monitor window: {window!r}")
15171494
return {"status_code": int(status_code)}
@@ -1522,14 +1499,42 @@ def handle_request():
15221499
return _route_with_runtime_error_fallback(_handle_request)
15231500

15241501

1502+
def _handle_dry_run_with_deadline():
1503+
try:
1504+
with enforce_runtime_deadline(
1505+
IBKR_DRY_RUN_DEADLINE_SECONDS,
1506+
operation="IBKR strategy dry-run request",
1507+
):
1508+
return _route_with_runtime_error_fallback(
1509+
_handle_request,
1510+
dry_run_only_override=True,
1511+
response_body="Dry Run OK",
1512+
dry_run_label="strategy dry-run",
1513+
)
1514+
except RuntimeDeadlineExceeded as exc:
1515+
print(
1516+
json.dumps(
1517+
{
1518+
"severity": "ERROR",
1519+
"event": "strategy_cycle_deadline_exceeded",
1520+
"message": str(exc),
1521+
"service_name": SERVICE_NAME or os.getenv("K_SERVICE"),
1522+
"strategy_profile": STRATEGY_PROFILE,
1523+
"timeout_seconds": IBKR_DRY_RUN_DEADLINE_SECONDS,
1524+
"worker_recycle_scheduled": True,
1525+
},
1526+
ensure_ascii=False,
1527+
separators=(",", ":"),
1528+
),
1529+
flush=True,
1530+
)
1531+
recycle_current_process_after_response()
1532+
return "Error", 503
1533+
1534+
15251535
@app.route("/dry-run", methods=["POST", "GET"])
15261536
def handle_dry_run():
1527-
return _route_with_runtime_error_fallback(
1528-
_handle_request,
1529-
dry_run_only_override=True,
1530-
response_body="Dry Run OK",
1531-
dry_run_label="strategy dry-run",
1532-
)
1537+
return _handle_dry_run_with_deadline()
15331538

15341539

15351540
@app.route("/probe", methods=["POST", "GET"])

tests/test_request_handling.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ def fake_run_strategy_core(**kwargs):
154154

155155

156156
def test_handle_dry_run_returns_service_unavailable_before_platform_timeout(strategy_module, monkeypatch):
157-
observed = {"events": [], "notifications": []}
157+
observed = {"recycles": 0}
158158

159159
class ExpiredDeadline:
160160
def __enter__(self):
@@ -163,36 +163,55 @@ def __enter__(self):
163163
def __exit__(self, *_args):
164164
return False
165165

166-
monkeypatch.setattr(strategy_module, "build_request_log_context", lambda: types.SimpleNamespace(run_id="run-001"))
167-
monkeypatch.setattr(strategy_module, "build_execution_report", lambda *_args, **_kwargs: {"status": "pending", "errors": []})
166+
monkeypatch.setattr(strategy_module, "enforce_runtime_deadline", lambda *_args, **_kwargs: ExpiredDeadline())
168167
monkeypatch.setattr(
169168
strategy_module,
170-
"persist_execution_report",
171-
lambda report, **_kwargs: observed.setdefault("report", dict(report)) or "/tmp/runtime-report.json",
169+
"recycle_current_process_after_response",
170+
lambda: observed.__setitem__("recycles", observed["recycles"] + 1),
172171
)
172+
173+
with strategy_module.app.test_request_context("/dry-run", method="POST"):
174+
body, status = strategy_module.handle_dry_run()
175+
176+
assert status == 503
177+
assert body == "Error"
178+
assert observed["recycles"] == 1
179+
180+
181+
def test_handle_dry_run_deadline_from_strategy_skips_persistence_and_recycles(strategy_module, monkeypatch):
182+
observed = {"persist_calls": 0, "recycles": 0}
183+
184+
monkeypatch.setattr(strategy_module, "build_request_log_context", lambda: types.SimpleNamespace(run_id="run-001"))
185+
monkeypatch.setattr(strategy_module, "build_execution_report", lambda *_args, **_kwargs: {"status": "pending"})
173186
monkeypatch.setattr(strategy_module, "is_market_open_now", lambda **_kwargs: True)
174187
monkeypatch.setattr(strategy_module, "load_strategy_plugin_signals", lambda: ((), None))
175188
monkeypatch.setattr(strategy_module, "attach_strategy_plugin_report", lambda *_args, **_kwargs: None)
176-
monkeypatch.setattr(strategy_module, "enforce_runtime_deadline", lambda *_args, **_kwargs: ExpiredDeadline())
189+
monkeypatch.setattr(strategy_module, "log_runtime_event", lambda *_args, **_kwargs: None)
177190
monkeypatch.setattr(
178191
strategy_module,
179-
"log_runtime_event",
180-
lambda _context, event, **fields: observed["events"].append((event, fields)),
192+
"run_strategy_core",
193+
lambda **_kwargs: (_ for _ in ()).throw(
194+
strategy_module.RuntimeDeadlineExceeded("strategy deadline exceeded")
195+
),
196+
)
197+
monkeypatch.setattr(
198+
strategy_module,
199+
"persist_execution_report",
200+
lambda *_args, **_kwargs: observed.__setitem__("persist_calls", observed["persist_calls"] + 1),
181201
)
182202
monkeypatch.setattr(
183203
strategy_module,
184-
"_publish_runtime_failure_notification",
185-
lambda **kwargs: observed["notifications"].append(kwargs) or True,
204+
"recycle_current_process_after_response",
205+
lambda: observed.__setitem__("recycles", observed["recycles"] + 1),
186206
)
187207

188208
with strategy_module.app.test_request_context("/dry-run", method="POST"):
189209
body, status = strategy_module.handle_dry_run()
190210

191211
assert status == 503
192212
assert body == "Error"
193-
assert observed["report"]["errors"][0]["stage"] == "strategy_deadline"
194-
assert observed["events"][-1][0] == "strategy_cycle_deadline_exceeded"
195-
assert len(observed["notifications"]) == 1
213+
assert observed["persist_calls"] == 0
214+
assert observed["recycles"] == 1
196215

197216

198217
def test_dry_run_composer_wires_dry_run_override_to_order_execution(strategy_module, monkeypatch):

tests/test_runtime_deadline.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44

55
import pytest
66

7-
from application.runtime_deadline import RuntimeDeadlineExceeded, enforce_runtime_deadline
7+
from application.runtime_deadline import (
8+
RuntimeDeadlineExceeded,
9+
enforce_runtime_deadline,
10+
recycle_current_process_after_response,
11+
)
812

913

1014
def test_runtime_deadline_interrupts_stalled_main_thread_work() -> None:
@@ -20,3 +24,16 @@ def test_runtime_deadline_interrupts_stalled_main_thread_work() -> None:
2024
def test_disabled_runtime_deadline_does_not_interrupt() -> None:
2125
with enforce_runtime_deadline(0, operation="disabled operation"):
2226
pass
27+
28+
29+
def test_recycle_current_process_sends_sigterm_after_delay() -> None:
30+
calls = []
31+
32+
thread = recycle_current_process_after_response(
33+
delay_seconds=0,
34+
process_id=1234,
35+
kill_fn=lambda process_id, signum: calls.append((process_id, signum)),
36+
)
37+
thread.join(timeout=1)
38+
39+
assert calls == [(1234, 15)]

0 commit comments

Comments
 (0)