-
Notifications
You must be signed in to change notification settings - Fork 2
fix(executor): task refs + pipeline semaphore + off-loop broker [修复组③] #27
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 |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| """Celery-based distributed executor.""" | ||
|
|
||
| import asyncio | ||
| import logging | ||
|
|
||
| from backend.executor.base import AbstractExecutor | ||
|
|
@@ -29,15 +30,23 @@ async def cancel_acquisition(self, execution_id: str) -> None: | |
|
|
||
| async def dispatch_collection(self, task_id: str, parameters: dict) -> dict: | ||
| from backend.worker.tasks import run_collection | ||
| result = run_collection.apply_async( | ||
| kwargs={"task_id": task_id, "parameters": parameters} | ||
|
|
||
| # AUDIT C9: apply_async is a synchronous broker round-trip (network | ||
| # I/O to Redis/RabbitMQ). Called directly here it would block this | ||
| # event loop for every dispatch; to_thread moves the round-trip off | ||
| # the loop. | ||
| result = await asyncio.to_thread( | ||
| run_collection.apply_async, | ||
| kwargs={"task_id": task_id, "parameters": parameters}, | ||
| ) | ||
| return {"task_id": task_id, "celery_task_id": result.id} | ||
|
|
||
| async def dispatch_scheduled_collection( | ||
| self, schedule_id: str, source_id: str, parameters: dict | ||
| ) -> None: | ||
| from backend.worker.tasks import run_scheduled_collection | ||
| run_scheduled_collection.apply_async( | ||
| kwargs={"schedule_id": schedule_id, "source_id": source_id, "parameters": parameters} | ||
|
|
||
| await asyncio.to_thread( | ||
| run_scheduled_collection.apply_async, | ||
| kwargs={"schedule_id": schedule_id, "source_id": source_id, "parameters": parameters}, | ||
|
Comment on lines
+38
to
+51
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. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== backend/executor/celery_exec.py ==\n'
wc -l backend/executor/celery_exec.py
sed -n '1,220p' backend/executor/celery_exec.py
printf '\n== search for celery_exec tests / cancellation ==\n'
rg -n "celery_exec|apply_async|to_thread|cancellation|CancelledError|dispatch_scheduled_collection|dispatch_collection" backend tests . -g '!**/node_modules/**' || true
printf '\n== asyncio.to_thread cancellation probe ==\n'
python3 - <<'PY'
import asyncio, threading, time
events = []
def work():
events.append(("work_start", threading.current_thread().name, time.time()))
time.sleep(0.2)
events.append(("work_end", threading.current_thread().name, time.time()))
return 123
async def main():
task = asyncio.create_task(asyncio.to_thread(work))
await asyncio.sleep(0.05)
task.cancel()
try:
await task
except asyncio.CancelledError:
events.append(("cancelled", time.time()))
await asyncio.sleep(0.3)
print(events)
asyncio.run(main())
PYRepository: 2233admin/opencli-admin Length of output: 31706 Guard the Celery enqueue against cancellation races. 🤖 Prompt for AI Agents |
||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,10 +3,29 @@ | |
| import asyncio | ||
| import logging | ||
|
|
||
| from backend.config import get_settings | ||
| from backend.executor.base import AbstractExecutor | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| # AUDIT C6: process-wide cap on concurrently-RUNNING pipeline executions | ||
| # (manual/webhook dispatch_collection + every dispatch_scheduled_collection), | ||
| # independent of the per-domain cap in pipeline/domain_limiter.py. Keyed by | ||
| # event loop id, mirroring domain_limiter._semaphore, so a semaphore is never | ||
| # reused across event loops (production runs one loop and shares correctly; | ||
| # tests get a fresh loop each and never touch a stale entry). | ||
| _pipeline_semaphores: dict[int, asyncio.Semaphore] = {} | ||
|
|
||
|
|
||
| def _pipeline_semaphore() -> asyncio.Semaphore: | ||
| loop = asyncio.get_running_loop() | ||
| key = id(loop) | ||
| sem = _pipeline_semaphores.get(key) | ||
| if sem is None: | ||
| sem = asyncio.Semaphore(get_settings().local_max_concurrent_pipelines) | ||
| _pipeline_semaphores[key] = sem | ||
|
Comment on lines
+17
to
+26
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. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Locate the executor implementation and its tests
git ls-files backend/executor backend | sed -n '1,200p'
# Map the local executor file structure
if [ -f backend/executor/local.py ]; then
echo "=== backend/executor/local.py ==="
cat -n backend/executor/local.py | sed -n '1,220p'
fi
# Find references to the semaphore registry and any test fixture cleanup
rg -n "_pipeline_semaphores|_pipeline_semaphore|get_running_loop|local_max_concurrent_pipelines|clear\\(" backend tests -S
# Find tests touching loop reuse / event loop fixtures
rg -n "event_loop|asyncio\\.Semaphore|loop id|id\\(loop\\)|closed loop|recycled" tests backend -SRepository: 2233admin/opencli-admin Length of output: 29677 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "=== backend/pipeline/domain_limiter.py ==="
cat -n backend/pipeline/domain_limiter.py | sed -n '1,140p'
echo "=== tests/unit/test_executor.py (fixture + semaphore tests) ==="
cat -n tests/unit/test_executor.py | sed -n '1,280p'
echo "=== tests/conftest.py (event loop fixture) ==="
cat -n tests/conftest.py | sed -n '1,140p'Repository: 2233admin/opencli-admin Length of output: 19453 🏁 Script executed: python3 - <<'PY'
import asyncio, gc, sys
print("python", sys.version)
# Probe 1: loop id recycling after creating/closing many loops.
ids = []
for _ in range(2000):
loop = asyncio.new_event_loop()
ids.append(id(loop))
loop.close()
del loop
gc.collect()
dups = len(ids) - len(set(ids))
print("loop_id_duplicates", dups)
if dups:
seen = set()
for i, x in enumerate(ids):
if x in seen:
print("first_reused_id", x, "at_index", i)
break
seen.add(x)
# Probe 2: create semaphore in one loop, then use it in another loop.
async def make_sem():
return asyncio.Semaphore(0)
loop1 = asyncio.new_event_loop()
try:
sem = loop1.run_until_complete(make_sem())
finally:
loop1.close()
async def try_acquire(s):
await asyncio.wait_for(s.acquire(), timeout=0.1)
return "acquired"
loop2 = asyncio.new_event_loop()
try:
result = loop2.run_until_complete(try_acquire(sem))
print("cross_loop_acquire", result)
except Exception as e:
print("cross_loop_acquire_error", type(e).__name__, str(e))
finally:
loop2.close()
PYRepository: 2233admin/opencli-admin Length of output: 324 Key the semaphore registry by the loop object, not 🤖 Prompt for AI Agents |
||
| return sem | ||
|
Comment on lines
3
to
+27
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. Using Using import asyncio
import logging
import weakref
from backend.config import get_settings
from backend.executor.base import AbstractExecutor
logger = logging.getLogger(__name__)
# AUDIT C6: process-wide cap on concurrently-RUNNING pipeline executions
# (manual/webhook dispatch_collection + every dispatch_scheduled_collection),
# independent of the per-domain cap in pipeline/domain_limiter.py. Keyed by
# event loop object using a WeakKeyDictionary, so semaphores are automatically
# cleaned up when loops are closed and garbage collected, preventing memory
# leaks and ID reuse bugs.
_pipeline_semaphores: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Semaphore] = weakref.WeakKeyDictionary()
def _pipeline_semaphore() -> asyncio.Semaphore:
loop = asyncio.get_running_loop()
sem = _pipeline_semaphores.get(loop)
if sem is None:
sem = asyncio.Semaphore(get_settings().local_max_concurrent_pipelines)
_pipeline_semaphores[loop] = sem
return sem |
||
|
|
||
|
|
||
| def _log_task_exception(task: asyncio.Task) -> None: | ||
| """Log any unhandled exception from a background asyncio task.""" | ||
|
|
@@ -21,6 +40,12 @@ def _log_task_exception(task: asyncio.Task) -> None: | |
| class LocalExecutor(AbstractExecutor): | ||
| def __init__(self) -> None: | ||
| self._acquisition_tasks: dict[str, asyncio.Task[None]] = {} | ||
| # AUDIT C5: distinct dicts from _acquisition_tasks — task_id (manual/ | ||
| # webhook dispatch) and schedule_id (scheduled dispatch) are different | ||
| # id spaces, so each gets its own strong-reference table instead of | ||
| # sharing one dict with two key schemes. | ||
| self._collection_tasks: dict[str, asyncio.Task[dict]] = {} | ||
| self._scheduled_collection_tasks: dict[str, asyncio.Task[dict]] = {} | ||
|
|
||
| async def dispatch_acquisition(self, execution_id: str) -> None: | ||
| from backend.acquisition.runner import run_acquisition_execution | ||
|
|
@@ -47,15 +72,59 @@ async def cancel_acquisition(self, execution_id: str) -> None: | |
| except asyncio.CancelledError: | ||
| pass | ||
|
|
||
| async def dispatch_collection(self, task_id: str, parameters: dict) -> dict: | ||
| async def _run_collection(self, task_id: str, parameters: dict) -> dict: | ||
| from backend.pipeline.runner import run_collection_pipeline | ||
| t = asyncio.create_task(run_collection_pipeline(task_id, parameters)) | ||
| t.add_done_callback(_log_task_exception) | ||
|
|
||
| # AUDIT C6: the semaphore wraps execution, not dispatch — the task | ||
| # blocks on it here, inside itself, so dispatch_collection stays fast | ||
| # and non-blocking. | ||
| async with _pipeline_semaphore(): | ||
| return await run_collection_pipeline(task_id, parameters) | ||
|
|
||
| async def _run_scheduled( | ||
| self, schedule_id: str, source_id: str, parameters: dict | ||
| ) -> dict: | ||
| from backend.pipeline.runner import run_scheduled_pipeline | ||
|
|
||
| async with _pipeline_semaphore(): | ||
| return await run_scheduled_pipeline(schedule_id, source_id, parameters) | ||
|
|
||
| async def dispatch_collection(self, task_id: str, parameters: dict) -> dict: | ||
| # AUDIT C5: hold a strong reference exactly like dispatch_acquisition | ||
| # does. task_id is unique per call (each manual/webhook trigger has | ||
| # its own CollectionTask row), so unlike dispatch_scheduled_collection | ||
| # below there is no skip-if-inflight check — just track-and-pop. The | ||
| # task is stored before it can even attempt to acquire the semaphore, | ||
| # so it stays referenced (and un-GC'd) for the whole time it's | ||
| # blocked waiting on _pipeline_semaphore(), not just while running. | ||
| task = asyncio.create_task(self._run_collection(task_id, parameters)) | ||
| task.add_done_callback(_log_task_exception) | ||
| self._collection_tasks[task_id] = task | ||
| task.add_done_callback( | ||
| lambda completed: self._collection_tasks.pop(task_id, None) | ||
| if self._collection_tasks.get(task_id) is completed | ||
| else None | ||
| ) | ||
| return {"task_id": task_id} | ||
|
|
||
| async def dispatch_scheduled_collection( | ||
| self, schedule_id: str, source_id: str, parameters: dict | ||
| ) -> None: | ||
| from backend.pipeline.runner import run_scheduled_pipeline | ||
| t = asyncio.create_task(run_scheduled_pipeline(schedule_id, source_id, parameters)) | ||
| t.add_done_callback(_log_task_exception) | ||
| # AUDIT C5: schedule_id has no per-run unique id at dispatch time (a | ||
| # schedule fires the same schedule_id on every tick), so this mirrors | ||
| # dispatch_acquisition's skip-if-inflight precedent — a schedule whose | ||
| # previous run hasn't finished yet is skipped rather than allowed to | ||
| # overlap itself. | ||
| current = self._scheduled_collection_tasks.get(schedule_id) | ||
| if current is not None and not current.done(): | ||
| return | ||
| task = asyncio.create_task( | ||
| self._run_scheduled(schedule_id, source_id, parameters) | ||
| ) | ||
| task.add_done_callback(_log_task_exception) | ||
| self._scheduled_collection_tasks[schedule_id] = task | ||
| task.add_done_callback( | ||
| lambda completed: self._scheduled_collection_tasks.pop(schedule_id, None) | ||
| if self._scheduled_collection_tasks.get(schedule_id) is completed | ||
| else None | ||
| ) | ||
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.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: 2233admin/opencli-admin
Length of output: 5930
🏁 Script executed:
Repository: 2233admin/opencli-admin
Length of output: 10628
🏁 Script executed:
Repository: 2233admin/opencli-admin
Length of output: 4255
🏁 Script executed:
Repository: 2233admin/opencli-admin
Length of output: 3334
Reject non-positive concurrency limits.
0leaves the local executor semaphore permanently locked, and negatives raiseValueErrorwhen it is created; constrainlocal_max_concurrent_pipelinesto> 0and add boundary tests.🤖 Prompt for AI Agents