Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ class Settings(BaseSettings):
# Task execution mode: "local" (in-process asyncio) or "celery" (distributed)
task_executor: Literal["local", "celery"] = "local"

# AUDIT C6: process-wide cap on concurrently-RUNNING pipeline executions in
# the local (in-process asyncio) executor — independent of the per-domain
# cap in pipeline/domain_limiter.py. Bounds how many schedules firing on
# the same tick plus manual/webhook triggers can drive Chrome/opencli
# subprocesses at once on the one event loop. Only meaningful when
# task_executor="local" (celery fans out across worker processes
# instead). Env: LOCAL_MAX_CONCURRENT_PIPELINES.
local_max_concurrent_pipelines: int = 8
Comment on lines +27 to +34

Copy link
Copy Markdown

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:

#!/bin/bash
set -euo pipefail

printf '\n== backend/config.py ==\n'
ast-grep outline backend/config.py --view expanded || true

printf '\n== relevant settings/tests search ==\n'
rg -n "local_max_concurrent_pipelines|LOCAL_MAX_CONCURRENT_PIPELINES|Semaphore|concurrent pipelines" backend tests . || true

Repository: 2233admin/opencli-admin

Length of output: 5930


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== backend/executor/local.py ==\n'
cat -n backend/executor/local.py

printf '\n== tests/unit/test_executor.py (relevant section) ==\n'
sed -n '180,260p' tests/unit/test_executor.py

printf '\n== runtime probe: asyncio.Semaphore and pydantic settings coercion ==\n'
python3 - <<'PY'
import asyncio
import os
from pydantic import BaseSettings

class S(BaseSettings):
    x: int = 8

for value in ["0", "-1", "3"]:
    os.environ["X"] = value
    try:
        s = S()
        print(f"env={value!r} -> parsed={s.x}")
        sem = asyncio.Semaphore(s.x)
        print(f"  semaphore._value={sem._value}")
    except Exception as e:
        print(f"env={value!r} -> ERROR {type(e).__name__}: {e}")
PY

Repository: 2233admin/opencli-admin

Length of output: 10628


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== backend/config.py (top) ==\n'
sed -n '1,80p' backend/config.py | cat -n

printf '\n== runtime probe: actual backend.config.Settings parsing ==\n'
python3 - <<'PY'
import os
from contextlib import contextmanager

`@contextmanager`
def setenv(key, value):
    old = os.environ.get(key)
    os.environ[key] = value
    try:
        yield
    finally:
        if old is None:
            os.environ.pop(key, None)
        else:
            os.environ[key] = old

from backend.config import Settings

for value in ["0", "-1", "3"]:
    with setenv("LOCAL_MAX_CONCURRENT_PIPELINES", value):
        try:
            s = Settings()
            print(f"value={value!r} -> parsed={s.local_max_concurrent_pipelines!r}")
        except Exception as e:
            print(f"value={value!r} -> ERROR {type(e).__name__}: {e}")
PY

Repository: 2233admin/opencli-admin

Length of output: 4255


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import asyncio
import inspect

for value in [0, -1, 1]:
    try:
        sem = asyncio.Semaphore(value)
        print(f"Semaphore({value}) -> ok, initial={sem._value}")
    except Exception as e:
        print(f"Semaphore({value}) -> {type(e).__name__}: {e}")

print("\n--- asyncio.Semaphore source snippet ---")
src = inspect.getsource(asyncio.Semaphore)
print(src)
PY

Repository: 2233admin/opencli-admin

Length of output: 3334


Reject non-positive concurrency limits. 0 leaves the local executor semaphore permanently locked, and negatives raise ValueError when it is created; constrain local_max_concurrent_pipelines to > 0 and add boundary tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/config.py` around lines 27 - 34, The local_max_concurrent_pipelines
configuration currently accepts invalid non-positive values. Validate this
setting as strictly greater than zero during configuration loading or
validation, preserving the existing default of 8, and add boundary tests
covering zero and negative values being rejected.


# Collection orchestrator:
# admin — API内置 scheduler.py / Celery Beat 驱动定时采集(默认)
# iii — III engine + schedule-bootstrap 驱动 cron;API 仅保留 UI/手动任务
Expand Down
17 changes: 13 additions & 4 deletions backend/executor/celery_exec.py
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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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())
PY

Repository: 2233admin/opencli-admin

Length of output: 31706


Guard the Celery enqueue against cancellation races. asyncio.to_thread() can keep running after CancelledError, so a retry may enqueue duplicate collection or scheduled work in the background. Let the broker call finish before re-raising cancellation, and add a cancellation regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/executor/celery_exec.py` around lines 38 - 51, Update the Celery
enqueue flows in the collection dispatch method and
dispatch_scheduled_collection to shield or otherwise await the in-flight
asyncio.to_thread broker call after cancellation, allowing apply_async to finish
before re-raising CancelledError. Preserve the existing task arguments and
return behavior, and add regression coverage verifying cancellation cannot leave
collection or scheduled work enqueued in the background or duplicated on retry.

)
81 changes: 75 additions & 6 deletions backend/executor/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

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:

#!/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 -S

Repository: 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()
PY

Repository: 2233admin/opencli-admin

Length of output: 324


Key the semaphore registry by the loop object, not id(loop). id(loop) is reused quickly, and this registry never evicts entries, so a new loop can pick up a stale semaphore from a closed one. Use the loop object (or a weak-key registry) to keep semaphore ownership tied to loop lifetime.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/executor/local.py` around lines 17 - 26, Update _pipeline_semaphore
and _pipeline_semaphores to key entries by the asyncio event loop object rather
than id(loop), ensuring each loop retrieves only its own semaphore. Preserve the
existing per-loop creation and configured concurrency limit while avoiding stale
semaphore reuse across closed and newly created loops.

return sem
Comment on lines 3 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using id(loop) as a key in a global dictionary is prone to bugs because Python's id() is only guaranteed to be unique during the lifetime of the object. When an event loop is closed and garbage collected (especially during tests or loop restarts), its memory address (and thus its id) can be recycled for a newly created event loop. This causes the new loop to reuse a stale semaphore bound to the old loop, leading to RuntimeError or unexpected concurrency limits.

Using weakref.WeakKeyDictionary with the event loop object itself as the key resolves this cleanly. It automatically removes the semaphore entry when the event loop is garbage collected, preventing both memory leaks and ID recycling bugs without requiring manual cleanup fixtures in tests.

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."""
Expand All @@ -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
Expand All @@ -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
)
Loading
Loading