-
Notifications
You must be signed in to change notification settings - Fork 1
fix: recover deadlocked Binance receivers #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -66,6 +66,10 @@ | |
| MIN_FREE_GB = int(os.getenv("MIN_FREE_GB", "20")) | ||
| ZSTD_TIMEOUT_SECONDS = int(os.getenv("ZSTD_TIMEOUT_SECONDS", "300")) | ||
| OSS_COPY_TIMEOUT_SECONDS = int(os.getenv("OSS_COPY_TIMEOUT_SECONDS", "300")) | ||
| PROCESS_WATCHDOG_SECONDS = int(os.getenv("PROCESS_WATCHDOG_SECONDS", "180")) | ||
| TASK_CANCEL_TIMEOUT_SECONDS = int(os.getenv("TASK_CANCEL_TIMEOUT_SECONDS", "5")) | ||
| LAST_DATA_AT = time.monotonic() | ||
| PROCESS_WATCHDOG_ARMED = False | ||
| UPLOAD_STATUS: dict[str, str | None] = { | ||
| "last_success_at": None, | ||
| "last_error_at": None, | ||
|
|
@@ -94,6 +98,10 @@ def __init__(self, symbol: str, status: int) -> None: | |
| super().__init__(f"snapshot unavailable symbol={self.symbol} status={status}") | ||
|
|
||
|
|
||
| class TaskCancellationStuck(RuntimeError): | ||
| pass | ||
|
|
||
|
|
||
| class PendingBudget: | ||
| def __init__(self, limit: int) -> None: | ||
| self.limit = limit | ||
|
|
@@ -597,6 +605,7 @@ def fetch_snapshot_sync(symbol: str) -> dict: | |
|
|
||
|
|
||
| async def receive_url(url: str, queue: asyncio.Queue, stop: asyncio.Event) -> None: | ||
| global LAST_DATA_AT | ||
| async with connect( | ||
| url, open_timeout=20, ping_interval=20, max_size=8 * 1024 * 1024 | ||
| ) as websocket: | ||
|
|
@@ -610,6 +619,7 @@ async def receive_url(url: str, queue: asyncio.Queue, stop: asyncio.Event) -> No | |
| f"no depth frames for {STALL_TIMEOUT_SECONDS}s on websocket shard" | ||
| ) | ||
| if isinstance(message, str): | ||
| LAST_DATA_AT = time.monotonic() | ||
| await queue.put(("diff", time.time_ns(), json.loads(message))) | ||
|
|
||
|
|
||
|
|
@@ -661,6 +671,43 @@ def is_stalled(last_frame_at: float, now: float) -> bool: | |
| return now - last_frame_at > STALL_TIMEOUT_SECONDS | ||
|
|
||
|
|
||
| def process_watchdog_expired(last_data_at: float, now: float) -> bool: | ||
| return now - last_data_at > PROCESS_WATCHDOG_SECONDS | ||
|
|
||
|
|
||
| def run_process_watchdog() -> None: | ||
| interval = max(1.0, min(10.0, PROCESS_WATCHDOG_SECONDS / 4)) | ||
| while True: | ||
| time.sleep(interval) | ||
| if not PROCESS_WATCHDOG_ARMED: | ||
| continue | ||
| now = time.monotonic() | ||
| if process_watchdog_expired(LAST_DATA_AT, now): | ||
| LOG.critical( | ||
| "process watchdog exiting after %.1fs without market data", | ||
| now - LAST_DATA_AT, | ||
| ) | ||
| os._exit(75) | ||
|
|
||
|
|
||
| async def cancel_tasks_bounded(tasks: tuple[asyncio.Task, ...]) -> None: | ||
| if not tasks: | ||
| return | ||
| for task in tasks: | ||
| task.cancel() | ||
| _, pending = await asyncio.wait( | ||
| tasks, timeout=TASK_CANCEL_TIMEOUT_SECONDS | ||
| ) | ||
| if pending: | ||
| LOG.error( | ||
| "task cancellation timed out pending=%s", | ||
| len(pending), | ||
| ) | ||
| raise TaskCancellationStuck( | ||
| f"task cancellation timed out pending={len(pending)}" | ||
| ) | ||
|
|
||
|
|
||
| def bridge_timed_out( | ||
| previously_synced: bool, sync_deadline: float | None, now: float | ||
| ) -> bool: | ||
|
|
@@ -981,11 +1028,7 @@ async def run_session( | |
| failure = error | ||
| raise | ||
| finally: | ||
| for task in [*tasks, *resync_tasks.values()]: | ||
| task.cancel() | ||
| await asyncio.gather( | ||
| *tasks, *resync_tasks.values(), return_exceptions=True | ||
| ) | ||
| await cancel_tasks_bounded(tuple([*tasks, *resync_tasks.values()])) | ||
| archive_only = isinstance(failure, SequenceGap) | ||
| drain_gap: SequenceGap | None = None | ||
| while not queue.empty(): | ||
|
|
@@ -1030,8 +1073,11 @@ async def run_session( | |
|
|
||
| async def collect(stop: asyncio.Event) -> None: | ||
| global SYMBOLS, SECURITY_TOKEN_SYMBOLS, EXCLUDED_SYMBOLS | ||
| global LAST_DATA_AT, PROCESS_WATCHDOG_ARMED | ||
| SPOOL_DIR.mkdir(parents=True, exist_ok=True) | ||
| recover_parts() | ||
| LAST_DATA_AT = time.monotonic() | ||
| PROCESS_WATCHDOG_ARMED = True | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Arming the process watchdog for the entire collector also leaves it active while Useful? React with 👍 / 👎. |
||
| runtime = ArchiveRuntime() | ||
| backoff = 1 | ||
| initial_budget = PendingBudget(MAX_PENDING_DIFFS_TOTAL) | ||
|
|
@@ -1081,6 +1127,15 @@ async def collect(stop: asyncio.Event) -> None: | |
| ) | ||
| await asyncio.sleep(backoff) | ||
| backoff = min(backoff * 2, 30) | ||
| except TaskCancellationStuck: | ||
| write_health( | ||
| states, session_id, "fatal_task_stall", runtime.total_gaps | ||
| ) | ||
| LOG.critical( | ||
| "receiver cancellation stuck; exiting for systemd restart", | ||
| exc_info=True, | ||
| ) | ||
| raise | ||
| except Exception: | ||
| write_health(states, session_id, "reconnecting", runtime.total_gaps) | ||
| LOG.exception("websocket session failed; reconnecting in %ss", backoff) | ||
|
|
@@ -1152,6 +1207,11 @@ def main() -> None: | |
| if STARTUP_DELAY_SECONDS: | ||
| LOG.info("startup delay=%ss", STARTUP_DELAY_SECONDS) | ||
| time.sleep(STARTUP_DELAY_SECONDS) | ||
| threading.Thread( | ||
| target=run_process_watchdog, | ||
| name=f"binance-data-watchdog-{MARKET}", | ||
| daemon=True, | ||
| ).start() | ||
| loop.run_until_complete(collect(stop)) | ||
|
|
||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
cancel_tasks_boundedtimes out, it raises here before the existing queue-drain loop runs. In the new stuck-close fatal path, any diff/snapshot frames already read from the websocket and sitting inqueueare discarded on process restart instead of being written as archived-only/replayed-unsafe as the following drain logic intends; preserve the timeout error but flush the queued frames before re-raising it.Useful? React with 👍 / 👎.