mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 13:40:37 +00:00
* fix(uploads): keep document-conversion stat and write-back off the event loop convert_file_to_markdown offloads the conversion itself for files above 1 MB (documented in its docstring), but the two bookend filesystem calls still ran on the event loop: the size stat before the threshold check and the write of the converted markdown afterwards — the latter a potentially multi-megabyte write in the per-upload ingestion path. Route both through run_file_io (the dedicated file IO pool that also keeps the caller's context), matching the off-the-loop fixes already applied to the sibling upload/document paths. Add a tests/blocking_io anchor: with the converter patched to a 2 MB payload it fails on the previous head (Blockbuster catches the blocking os.stat first; the broad except swallows it into a None return) and passes with this change. * fix(uploads): address review — drain conversion write-back, make run_file_io re-entrant Review follow-up on the off-loop conversion stat/write PR: 1. The write-back now goes through await_drained(): a plain run_file_io await cancels immediately while the worker keeps writing, so the ingestion scope could remove its private directory over an in-flight write and leave a large hidden partial file. The drain delivers the cancellation only after the worker settled, and the handler removes the partial output before re-raising. 2. run_file_io is now re-entrant: a nested offload issued from code already running inside a file-io worker (project-document conversion runs asyncio.run(convert_file_to_markdown(...)) inside a run_file_io worker) executes inline instead of submitting to the same bounded executor, which deadlocked with a single configured worker. Both fixes carry deterministic regressions: the cancellation test pins the drain ordering (cancel issued before the worker is released) and the partial-file cleanup; the re-entrancy test pins inline execution by thread identity. Both fail on the previous head. Full blocking_io + project-document + conversion suites show an identical failure set to the previous head (pre-existing Windows platform failures only). * fix(uploads): keep cancellation authoritative when conversion cleanup fails If the post-cancellation unlink of the staging output raised, the new exception escaped the CancelledError handler, was swallowed by the broad except as an ordinary conversion failure (None return), and left both the caller's cancellation and the partial file behind. Catch and log cleanup failures inside the handler and always re-raise the captured cancellation; a second cancellation still passes through untouched (CancelledError is not an Exception). Red/green: tests/blocking_io/test_file_conversion_offloop.py gains a regression where unlink fails while the write-back is in flight — on the reviewed head the task settled on None (DID NOT RAISE); with the change the CancelledError propagates and the failure is logged. Signed-off-by: yetuge <2219677952@qq.com> --------- Signed-off-by: yetuge <2219677952@qq.com>
110 lines
4.0 KiB
Python
110 lines
4.0 KiB
Python
"""Dedicated async offload helper for filesystem work."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import atexit
|
|
import contextvars
|
|
import functools
|
|
import logging
|
|
import os
|
|
import threading
|
|
from collections.abc import Callable, Coroutine
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _default_file_io_workers() -> int:
|
|
raw = os.getenv("DEER_FLOW_FILE_IO_WORKERS")
|
|
if raw:
|
|
try:
|
|
workers = int(raw)
|
|
if workers > 0:
|
|
return workers
|
|
except ValueError:
|
|
pass
|
|
logger.warning("Invalid DEER_FLOW_FILE_IO_WORKERS value; using default file IO worker count")
|
|
return min(32, (os.cpu_count() or 1) + 4)
|
|
|
|
|
|
_FILE_IO_EXECUTOR = ThreadPoolExecutor(max_workers=_default_file_io_workers(), thread_name_prefix="file-io")
|
|
|
|
# Marks the threads of ``_FILE_IO_EXECUTOR`` so ``run_file_io`` can detect
|
|
# (and run inline on) re-entrant calls.
|
|
_FILE_IO_WORKER = threading.local()
|
|
|
|
|
|
def _shutdown_file_io_executor() -> None:
|
|
_FILE_IO_EXECUTOR.shutdown(wait=False, cancel_futures=True)
|
|
|
|
|
|
atexit.register(_shutdown_file_io_executor)
|
|
|
|
|
|
def _worker_call(call: Callable[[], Any]) -> Any:
|
|
"""Run *call* inside a file-IO worker, marking the thread as such.
|
|
|
|
The marker makes ``run_file_io`` re-entrant: a nested offload issued from
|
|
code already running in a worker (e.g. ``asyncio.run`` inside a worker
|
|
body) runs inline instead of submitting to the same bounded executor,
|
|
which would deadlock once every worker is busy — deterministically so
|
|
with a single configured worker.
|
|
"""
|
|
_FILE_IO_WORKER.active = True
|
|
try:
|
|
return call()
|
|
finally:
|
|
_FILE_IO_WORKER.active = False
|
|
|
|
|
|
async def run_file_io[**P, T](func: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs) -> T:
|
|
"""Run blocking filesystem-oriented work on the dedicated file IO pool.
|
|
|
|
``asyncio.to_thread`` copies ``ContextVar`` values automatically; raw
|
|
``loop.run_in_executor`` does not. Copy the current context explicitly so
|
|
user-scoped helpers such as ``get_effective_user_id()`` keep working inside
|
|
the worker thread.
|
|
|
|
Re-entrant: called from inside a file-IO worker thread, *func* runs inline
|
|
rather than submitting to the pool again — the pool is bounded, so a
|
|
nested submit can deadlock when every worker is already busy.
|
|
"""
|
|
call = functools.partial(func, *args, **kwargs)
|
|
if getattr(_FILE_IO_WORKER, "active", False):
|
|
return call()
|
|
loop = asyncio.get_running_loop()
|
|
ctx = contextvars.copy_context()
|
|
return await loop.run_in_executor(_FILE_IO_EXECUTOR, ctx.run, _worker_call, call)
|
|
|
|
|
|
async def await_drained[T](coro: Coroutine[Any, Any, T]) -> T:
|
|
"""Await *coro* to completion even when the caller is cancelled meanwhile.
|
|
|
|
A locked database transaction that awaits a filesystem offload must not
|
|
release its row lock over in-flight worker work: cancelling the ``await``
|
|
abandons the offload's thread, not the work, so the lock could unwind
|
|
while the worker is still mutating files (e.g. a cancelled purge rolling
|
|
back and unlocking while its unlink worker is still running — a
|
|
concurrent restore could then validate bytes the abandoned worker is
|
|
about to delete). This shield+drain delivers the cancellation only after
|
|
*coro* has fully finished, so the transaction resolves coherently first.
|
|
The inner outcome is retrieved before re-raising (no "exception never
|
|
retrieved" noise).
|
|
"""
|
|
task = asyncio.ensure_future(coro)
|
|
try:
|
|
return await asyncio.shield(task)
|
|
except asyncio.CancelledError:
|
|
# Drain: the shielded inner task keeps running; absorb repeated
|
|
# cancellation until it finishes, then let the cancellation through.
|
|
while not task.done():
|
|
try:
|
|
await asyncio.wait({task})
|
|
except asyncio.CancelledError:
|
|
continue
|
|
if not task.cancelled():
|
|
task.exception()
|
|
raise
|