dwyeapi 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dwyeapi/__init__.py +20 -0
- dwyeapi/cache.py +30 -0
- dwyeapi/config.py +39 -0
- dwyeapi/database.py +42 -0
- dwyeapi/dependencies.py +16 -0
- dwyeapi/dt.py +52 -0
- dwyeapi/exceptions.py +75 -0
- dwyeapi/logger.py +205 -0
- dwyeapi/masking.py +107 -0
- dwyeapi/pagination.py +25 -0
- dwyeapi/response.py +19 -0
- dwyeapi/security.py +50 -0
- dwyeapi/tasks/__init__.py +97 -0
- dwyeapi/tasks/context.py +180 -0
- dwyeapi/tasks/model.py +42 -0
- dwyeapi/tasks/pool.py +81 -0
- dwyeapi/tasks/registry.py +83 -0
- dwyeapi/tasks/router.py +196 -0
- dwyeapi/tasks/schema.py +38 -0
- dwyeapi/tasks/service.py +145 -0
- dwyeapi/tasks/worker.py +118 -0
- dwyeapi-0.4.0.dist-info/METADATA +23 -0
- dwyeapi-0.4.0.dist-info/RECORD +25 -0
- dwyeapi-0.4.0.dist-info/WHEEL +5 -0
- dwyeapi-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Async task processing system for FastAPI (ARQ-based).
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
# 1. Install
|
|
6
|
+
pip install dwyeapi[tasks]
|
|
7
|
+
|
|
8
|
+
# 2. Register tasks
|
|
9
|
+
from dwyeapi.tasks import register, TaskContext
|
|
10
|
+
|
|
11
|
+
@register("process_data")
|
|
12
|
+
async def process_data(ctx: TaskContext, params: dict):
|
|
13
|
+
await ctx.log("Processing...")
|
|
14
|
+
await ctx.update_progress(50)
|
|
15
|
+
return {"done": True}
|
|
16
|
+
|
|
17
|
+
# 3. Mount in FastAPI
|
|
18
|
+
from dwyeapi.tasks import setup_tasks, task_router
|
|
19
|
+
|
|
20
|
+
await setup_tasks(app, settings, session_factory)
|
|
21
|
+
app.include_router(task_router)
|
|
22
|
+
|
|
23
|
+
# 4. Run worker
|
|
24
|
+
# arq app.worker.WorkerSettings
|
|
25
|
+
|
|
26
|
+
Public API:
|
|
27
|
+
setup_tasks — One-call initialization (ARQ pool + store session factory)
|
|
28
|
+
task_router — Ready-to-use APIRouter with CRUD endpoints
|
|
29
|
+
register — Decorator to register an async task function
|
|
30
|
+
TaskContext — Context object injected into task functions
|
|
31
|
+
TaskStatus — Task lifecycle status enum
|
|
32
|
+
create_worker_settings — Factory to generate ARQ WorkerSettings from BaseSettings
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from fastapi import FastAPI
|
|
38
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
39
|
+
|
|
40
|
+
from dwyeapi.config import BaseSettings
|
|
41
|
+
from dwyeapi.tasks.context import TaskContext
|
|
42
|
+
from dwyeapi.tasks.model import TaskStatus
|
|
43
|
+
from dwyeapi.tasks.registry import register
|
|
44
|
+
from dwyeapi.tasks.router import task_router
|
|
45
|
+
from dwyeapi.tasks.worker import create_worker_settings
|
|
46
|
+
|
|
47
|
+
# Re-export for convenient imports
|
|
48
|
+
__all__ = [
|
|
49
|
+
"TaskContext",
|
|
50
|
+
"TaskStatus",
|
|
51
|
+
"create_worker_settings",
|
|
52
|
+
"register",
|
|
53
|
+
"setup_tasks",
|
|
54
|
+
"task_router",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def setup_tasks(
|
|
59
|
+
app: FastAPI,
|
|
60
|
+
settings: BaseSettings,
|
|
61
|
+
session_factory: async_sessionmaker[AsyncSession],
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Initialize the task system.
|
|
64
|
+
|
|
65
|
+
Must be called during application startup (e.g. inside a lifespan).
|
|
66
|
+
|
|
67
|
+
1. Configures the ARQ Redis pool from ``settings.redis_url``
|
|
68
|
+
2. Stores the session_factory for TaskContext database access
|
|
69
|
+
3. Registers a shutdown hook to close the pool
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
app: The FastAPI application instance.
|
|
73
|
+
settings: An eapi BaseSettings instance with redis_url and task_* fields.
|
|
74
|
+
session_factory: Async SQLAlchemy session factory for database access.
|
|
75
|
+
|
|
76
|
+
Example::
|
|
77
|
+
|
|
78
|
+
@asynccontextmanager
|
|
79
|
+
async def lifespan(app: FastAPI):
|
|
80
|
+
await setup_tasks(app, settings, session_factory)
|
|
81
|
+
yield
|
|
82
|
+
|
|
83
|
+
app = FastAPI(lifespan=lifespan)
|
|
84
|
+
app.include_router(task_router)
|
|
85
|
+
"""
|
|
86
|
+
from dwyeapi.dependencies import create_get_db
|
|
87
|
+
from dwyeapi.tasks import pool as _pool
|
|
88
|
+
from dwyeapi.tasks import router as _router
|
|
89
|
+
|
|
90
|
+
# Configure and warm up the ARQ pool
|
|
91
|
+
_pool.configure(settings.redis_url)
|
|
92
|
+
|
|
93
|
+
# Wire the db dependency into the router
|
|
94
|
+
_router._get_db = create_get_db(session_factory)
|
|
95
|
+
|
|
96
|
+
# Store session_factory on app state for worker access
|
|
97
|
+
app.state.task_session_factory = session_factory
|
dwyeapi/tasks/context.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""TaskContext — rich context object injected into task functions.
|
|
2
|
+
|
|
3
|
+
Provides database access, logging, progress tracking, and cancellation
|
|
4
|
+
checking so that business task code stays minimal.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncIterator
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
14
|
+
|
|
15
|
+
from dwyeapi.tasks import service
|
|
16
|
+
from dwyeapi.tasks.model import TaskStatus
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TaskContext:
|
|
20
|
+
"""Execution context passed to every registered task function.
|
|
21
|
+
|
|
22
|
+
Provides helpers for logging, progress updates, cancellation checks,
|
|
23
|
+
and database access so that task functions only contain business logic.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
task_id: The unique identifier of the current task.
|
|
27
|
+
task_type: The registered task type string.
|
|
28
|
+
params: The parameters submitted with the task.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
task_id: str,
|
|
35
|
+
task_type: str,
|
|
36
|
+
params: dict[str, Any],
|
|
37
|
+
session_factory: async_sessionmaker[AsyncSession],
|
|
38
|
+
redis: Any,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Initialize TaskContext.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
task_id: Unique task identifier.
|
|
44
|
+
task_type: Registered task type name.
|
|
45
|
+
params: Task input parameters.
|
|
46
|
+
session_factory: Async SQLAlchemy session factory.
|
|
47
|
+
redis: ARQ Redis connection for cancel-flag checks.
|
|
48
|
+
"""
|
|
49
|
+
self.task_id = task_id
|
|
50
|
+
self.task_type = task_type
|
|
51
|
+
self.params = params
|
|
52
|
+
self._session_factory = session_factory
|
|
53
|
+
self._redis = redis
|
|
54
|
+
self._cancelled = False
|
|
55
|
+
|
|
56
|
+
async def log(self, message: str) -> None:
|
|
57
|
+
"""Append a timestamped log entry.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
message: The log message to record.
|
|
61
|
+
"""
|
|
62
|
+
async with self._session() as session:
|
|
63
|
+
await service.append_task_log(session, self.task_id, message)
|
|
64
|
+
|
|
65
|
+
async def update_progress(self, value: int, message: str = "") -> None:
|
|
66
|
+
"""Update task progress (0-100) and optionally append a log.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
value: Progress percentage (clamped to 0-100).
|
|
70
|
+
message: Optional log message to append alongside the progress update.
|
|
71
|
+
"""
|
|
72
|
+
async with self._session() as session:
|
|
73
|
+
await service.update_task_progress(session, self.task_id, value)
|
|
74
|
+
if message:
|
|
75
|
+
await service.append_task_log(session, self.task_id, message)
|
|
76
|
+
|
|
77
|
+
async def is_cancelled(self) -> bool:
|
|
78
|
+
"""Check whether a cancel request has been issued for this task.
|
|
79
|
+
|
|
80
|
+
Reads a Redis key ``task_cancel:{task_id}``. Once detected, the
|
|
81
|
+
result is cached so subsequent calls do not hit Redis again.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
True if the task has been marked for cancellation.
|
|
85
|
+
"""
|
|
86
|
+
if self._cancelled:
|
|
87
|
+
return True
|
|
88
|
+
val = await self._redis.get(f"task_cancel:{self.task_id}")
|
|
89
|
+
if val is not None:
|
|
90
|
+
self._cancelled = True
|
|
91
|
+
return self._cancelled
|
|
92
|
+
|
|
93
|
+
@asynccontextmanager
|
|
94
|
+
async def db(self) -> AsyncIterator[AsyncSession]:
|
|
95
|
+
"""Provide an async database session via context manager.
|
|
96
|
+
|
|
97
|
+
Usage::
|
|
98
|
+
|
|
99
|
+
async with ctx.db() as session:
|
|
100
|
+
result = await session.execute(select(MyModel))
|
|
101
|
+
|
|
102
|
+
Yields:
|
|
103
|
+
An AsyncSession bound to the application's database.
|
|
104
|
+
"""
|
|
105
|
+
async with self._session_factory() as session:
|
|
106
|
+
yield session
|
|
107
|
+
|
|
108
|
+
@asynccontextmanager
|
|
109
|
+
async def _session(self) -> AsyncIterator[AsyncSession]:
|
|
110
|
+
"""Internal helper — short-lived session for framework operations."""
|
|
111
|
+
async with self._session_factory() as session:
|
|
112
|
+
yield session
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def run_task_with_context(
|
|
116
|
+
task_id: str,
|
|
117
|
+
task_type: str,
|
|
118
|
+
params: dict[str, Any],
|
|
119
|
+
func: Any,
|
|
120
|
+
session_factory: async_sessionmaker[AsyncSession],
|
|
121
|
+
redis: Any,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Execute a task function wrapped in lifecycle management.
|
|
124
|
+
|
|
125
|
+
Handles the full task lifecycle:
|
|
126
|
+
1. Set status to RUNNING
|
|
127
|
+
2. Create TaskContext and call the business function
|
|
128
|
+
3. On success → set status to SUCCESS with returned result
|
|
129
|
+
4. On exception → set status to FAILED with error info
|
|
130
|
+
5. On cancellation → set status to CANCELED
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
task_id: The task's unique identifier.
|
|
134
|
+
task_type: The registered task type string.
|
|
135
|
+
params: The task input parameters.
|
|
136
|
+
func: The async business function to execute.
|
|
137
|
+
session_factory: Async SQLAlchemy session factory.
|
|
138
|
+
redis: ARQ Redis connection.
|
|
139
|
+
"""
|
|
140
|
+
ctx = TaskContext(
|
|
141
|
+
task_id=task_id,
|
|
142
|
+
task_type=task_type,
|
|
143
|
+
params=params,
|
|
144
|
+
session_factory=session_factory,
|
|
145
|
+
redis=redis,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Mark as running
|
|
149
|
+
async with session_factory() as session:
|
|
150
|
+
await service.update_task_status(session, task_id, TaskStatus.RUNNING)
|
|
151
|
+
await service.append_task_log(session, task_id, "任务开始执行")
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
result = await func(ctx, params)
|
|
155
|
+
|
|
156
|
+
# Check if cancelled during execution
|
|
157
|
+
if ctx._cancelled:
|
|
158
|
+
async with session_factory() as session:
|
|
159
|
+
await service.update_task_status(session, task_id, TaskStatus.CANCELED)
|
|
160
|
+
await service.append_task_log(session, task_id, "任务已取消")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
# Success
|
|
164
|
+
result_data = result if isinstance(result, dict) else {"result": result}
|
|
165
|
+
async with session_factory() as session:
|
|
166
|
+
await service.update_task_status(session, task_id, TaskStatus.SUCCESS, result=result_data)
|
|
167
|
+
await service.update_task_progress(session, task_id, 100)
|
|
168
|
+
await service.append_task_log(session, task_id, "任务执行成功")
|
|
169
|
+
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
# Failed
|
|
172
|
+
async with session_factory() as session:
|
|
173
|
+
await service.update_task_status(
|
|
174
|
+
session,
|
|
175
|
+
task_id,
|
|
176
|
+
TaskStatus.FAILED,
|
|
177
|
+
result={"error": str(exc)},
|
|
178
|
+
)
|
|
179
|
+
await service.append_task_log(session, task_id, f"任务执行失败: {exc}")
|
|
180
|
+
raise
|
dwyeapi/tasks/model.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Task ORM model and status enumeration."""
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import uuid as uuid_lib
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import JSON, Enum, Integer, String, Text
|
|
7
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
8
|
+
|
|
9
|
+
from dwyeapi.database import Base, TimestampMixin
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TaskStatus(enum.StrEnum):
|
|
13
|
+
"""Task lifecycle states."""
|
|
14
|
+
|
|
15
|
+
PENDING = "pending"
|
|
16
|
+
RUNNING = "running"
|
|
17
|
+
SUCCESS = "success"
|
|
18
|
+
FAILED = "failed"
|
|
19
|
+
CANCELED = "canceled"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _generate_task_id() -> str:
|
|
23
|
+
"""Generate a globally unique task ID."""
|
|
24
|
+
return f"task_{uuid_lib.uuid4().hex}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Task(Base, TimestampMixin):
|
|
28
|
+
"""Persistent task record.
|
|
29
|
+
|
|
30
|
+
Stores task metadata, execution state, progress, logs, and results.
|
|
31
|
+
Uses the same database as the host application via eapi's Base.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__tablename__ = "tasks"
|
|
35
|
+
|
|
36
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=_generate_task_id)
|
|
37
|
+
task_type: Mapped[str] = mapped_column(String(50), index=True)
|
|
38
|
+
status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), default=TaskStatus.PENDING)
|
|
39
|
+
params: Mapped[dict] = mapped_column(JSON)
|
|
40
|
+
result: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=None)
|
|
41
|
+
progress: Mapped[int] = mapped_column(Integer, default=0)
|
|
42
|
+
logs: Mapped[str] = mapped_column(Text, default="")
|
dwyeapi/tasks/pool.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""ARQ Redis connection pool management (internal module).
|
|
2
|
+
|
|
3
|
+
Manages the lifecycle of the ARQ async Redis pool used for enqueuing
|
|
4
|
+
and executing tasks. Not intended for direct use by integrators.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from arq import ArqRedis, create_pool
|
|
12
|
+
from arq.connections import RedisSettings
|
|
13
|
+
|
|
14
|
+
_pool: ArqRedis | None = None
|
|
15
|
+
_redis_settings: RedisSettings | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_redis_url(redis_url: str) -> RedisSettings:
|
|
19
|
+
"""Convert a redis:// URL to ARQ RedisSettings.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
redis_url: Standard Redis connection URL.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
ARQ RedisSettings instance.
|
|
26
|
+
"""
|
|
27
|
+
parsed = urlparse(redis_url)
|
|
28
|
+
return RedisSettings(
|
|
29
|
+
host=parsed.hostname or "localhost",
|
|
30
|
+
port=parsed.port or 6379,
|
|
31
|
+
database=int(parsed.path.lstrip("/") or "0"),
|
|
32
|
+
password=parsed.password,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def configure(redis_url: str) -> None:
|
|
37
|
+
"""Store Redis settings for later pool creation.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
redis_url: Standard Redis connection URL (e.g. redis://localhost:6379/0).
|
|
41
|
+
"""
|
|
42
|
+
global _redis_settings
|
|
43
|
+
_redis_settings = _parse_redis_url(redis_url)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_redis_settings() -> RedisSettings:
|
|
47
|
+
"""Return the configured ARQ RedisSettings.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The RedisSettings instance.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
RuntimeError: If configure() has not been called.
|
|
54
|
+
"""
|
|
55
|
+
if _redis_settings is None:
|
|
56
|
+
msg = "Task pool not configured. Call setup_tasks() first."
|
|
57
|
+
raise RuntimeError(msg)
|
|
58
|
+
return _redis_settings
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def get_pool() -> ArqRedis:
|
|
62
|
+
"""Get or create the ARQ async Redis pool.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The ArqRedis connection pool.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
RuntimeError: If configure() has not been called.
|
|
69
|
+
"""
|
|
70
|
+
global _pool
|
|
71
|
+
if _pool is None:
|
|
72
|
+
_pool = await create_pool(get_redis_settings())
|
|
73
|
+
return _pool
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def close_pool() -> None:
|
|
77
|
+
"""Close the ARQ pool if it exists. Safe to call multiple times."""
|
|
78
|
+
global _pool
|
|
79
|
+
if _pool is not None:
|
|
80
|
+
await _pool.aclose()
|
|
81
|
+
_pool = None
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Task function registry with decorator-based registration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Coroutine
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
# Type alias for an async task function: (TaskContext, dict) -> Any
|
|
9
|
+
TaskFunction = Callable[..., Coroutine[Any, Any, Any]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TaskRegistry:
|
|
13
|
+
"""Singleton registry that maps task_type strings to async functions.
|
|
14
|
+
|
|
15
|
+
Usage::
|
|
16
|
+
|
|
17
|
+
from dwyeapi.tasks import register
|
|
18
|
+
|
|
19
|
+
@register("process_data")
|
|
20
|
+
async def process_data(ctx: TaskContext, params: dict):
|
|
21
|
+
...
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
"""Initialize an empty registry."""
|
|
26
|
+
self._tasks: dict[str, TaskFunction] = {}
|
|
27
|
+
|
|
28
|
+
def register(self, task_type: str) -> Callable[[TaskFunction], TaskFunction]:
|
|
29
|
+
"""Decorator that registers an async function under a task_type.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
task_type: Unique string identifier for this task.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The original function, unmodified.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ValueError: If task_type is already registered.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def decorator(func: TaskFunction) -> TaskFunction:
|
|
42
|
+
if task_type in self._tasks:
|
|
43
|
+
msg = f"Task type '{task_type}' is already registered"
|
|
44
|
+
raise ValueError(msg)
|
|
45
|
+
self._tasks[task_type] = func
|
|
46
|
+
return func
|
|
47
|
+
|
|
48
|
+
return decorator
|
|
49
|
+
|
|
50
|
+
def get(self, task_type: str) -> TaskFunction | None:
|
|
51
|
+
"""Look up a registered task function by type.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
task_type: The task type to look up.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The registered function, or None if not found.
|
|
58
|
+
"""
|
|
59
|
+
return self._tasks.get(task_type)
|
|
60
|
+
|
|
61
|
+
def list_types(self) -> list[str]:
|
|
62
|
+
"""Return all registered task type strings.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Sorted list of registered task types.
|
|
66
|
+
"""
|
|
67
|
+
return sorted(self._tasks.keys())
|
|
68
|
+
|
|
69
|
+
def has(self, task_type: str) -> bool:
|
|
70
|
+
"""Check if a task type is registered.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
task_type: The task type to check.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
True if registered.
|
|
77
|
+
"""
|
|
78
|
+
return task_type in self._tasks
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Module-level singleton
|
|
82
|
+
registry = TaskRegistry()
|
|
83
|
+
register = registry.register
|
dwyeapi/tasks/router.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Ready-to-use FastAPI router for the task system.
|
|
2
|
+
|
|
3
|
+
Provides endpoints to submit, query, list, and cancel tasks.
|
|
4
|
+
Integrators mount it via ``app.include_router(task_router)``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncGenerator, Callable
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends, Query
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
|
+
|
|
14
|
+
from dwyeapi.exceptions import BusinessError, NotFoundError
|
|
15
|
+
from dwyeapi.response import success
|
|
16
|
+
from dwyeapi.tasks import pool
|
|
17
|
+
from dwyeapi.tasks.model import TaskStatus
|
|
18
|
+
from dwyeapi.tasks.registry import registry
|
|
19
|
+
from dwyeapi.tasks.schema import TaskCreate, TaskListResponse, TaskResponse
|
|
20
|
+
from dwyeapi.tasks.service import append_task_log, create_task, get_task, list_tasks, update_task_progress, update_task_status
|
|
21
|
+
|
|
22
|
+
task_router = APIRouter(prefix="/tasks", tags=["tasks"])
|
|
23
|
+
|
|
24
|
+
# This will be set by setup_tasks() at application startup.
|
|
25
|
+
_get_db: Callable[[], AsyncGenerator[AsyncSession]] | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _db_dependency() -> AsyncGenerator[AsyncSession]:
|
|
29
|
+
"""Async generator that delegates to the actual get_db dependency.
|
|
30
|
+
|
|
31
|
+
Yields:
|
|
32
|
+
An AsyncSession from the configured session factory.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
RuntimeError: If setup_tasks() has not been called.
|
|
36
|
+
"""
|
|
37
|
+
if _get_db is None:
|
|
38
|
+
msg = "Task system not initialized. Call setup_tasks() first."
|
|
39
|
+
raise RuntimeError(msg)
|
|
40
|
+
async for session in _get_db():
|
|
41
|
+
yield session
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@task_router.post("", summary="提交耗时任务")
|
|
45
|
+
async def submit_task(
|
|
46
|
+
body: TaskCreate,
|
|
47
|
+
db: AsyncSession = Depends(_db_dependency),
|
|
48
|
+
) -> dict:
|
|
49
|
+
"""Submit a new async task for background execution.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
body: Task creation payload with task_type and params.
|
|
53
|
+
db: Injected database session.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Unified success response containing the created task.
|
|
57
|
+
"""
|
|
58
|
+
if not registry.has(body.task_type):
|
|
59
|
+
raise BusinessError(f"不支持的任务类型: {body.task_type}", code="INVALID_TASK_TYPE")
|
|
60
|
+
|
|
61
|
+
task = await create_task(db, body)
|
|
62
|
+
|
|
63
|
+
# Enqueue into ARQ
|
|
64
|
+
arq_pool = await pool.get_pool()
|
|
65
|
+
await arq_pool.enqueue_job("_task_executor", task.id, body.task_type, body.params)
|
|
66
|
+
|
|
67
|
+
return success(data=TaskResponse.model_validate(task).model_dump(mode="json"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@task_router.get("/{task_id}", summary="查询任务状态")
|
|
71
|
+
async def query_task(
|
|
72
|
+
task_id: str,
|
|
73
|
+
db: AsyncSession = Depends(_db_dependency),
|
|
74
|
+
) -> dict:
|
|
75
|
+
"""Query the current state of a specific task.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
task_id: The task's unique identifier.
|
|
79
|
+
db: Injected database session.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Unified success response containing the task details.
|
|
83
|
+
"""
|
|
84
|
+
task = await get_task(db, task_id)
|
|
85
|
+
if not task:
|
|
86
|
+
raise NotFoundError("任务")
|
|
87
|
+
return success(data=TaskResponse.model_validate(task).model_dump(mode="json"))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@task_router.get("", summary="任务列表")
|
|
91
|
+
async def query_task_list(
|
|
92
|
+
db: AsyncSession = Depends(_db_dependency),
|
|
93
|
+
page: int = Query(default=1, ge=1, description="页码"),
|
|
94
|
+
page_size: int = Query(default=20, ge=1, le=100, description="每页条数"),
|
|
95
|
+
status: TaskStatus | None = Query(default=None, description="状态筛选"),
|
|
96
|
+
task_type: str | None = Query(default=None, description="任务类型筛选"),
|
|
97
|
+
) -> dict:
|
|
98
|
+
"""List tasks with pagination and optional filters.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
db: Injected database session.
|
|
102
|
+
page: 1-based page number.
|
|
103
|
+
page_size: Items per page (1-100).
|
|
104
|
+
status: Optional status filter.
|
|
105
|
+
task_type: Optional task type filter.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Unified success response containing paginated task list.
|
|
109
|
+
"""
|
|
110
|
+
items, total = await list_tasks(
|
|
111
|
+
db,
|
|
112
|
+
page=page,
|
|
113
|
+
page_size=page_size,
|
|
114
|
+
status=status,
|
|
115
|
+
task_type=task_type,
|
|
116
|
+
)
|
|
117
|
+
data = TaskListResponse(
|
|
118
|
+
items=[TaskResponse.model_validate(t) for t in items],
|
|
119
|
+
total=total,
|
|
120
|
+
)
|
|
121
|
+
return success(data=data.model_dump(mode="json"))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@task_router.post("/{task_id}/cancel", summary="取消任务")
|
|
125
|
+
async def cancel_task(
|
|
126
|
+
task_id: str,
|
|
127
|
+
db: AsyncSession = Depends(_db_dependency),
|
|
128
|
+
) -> dict:
|
|
129
|
+
"""Request cancellation of a running or pending task.
|
|
130
|
+
|
|
131
|
+
Sets a Redis flag that the task function can check via ``ctx.is_cancelled()``.
|
|
132
|
+
Tasks that have already completed cannot be cancelled.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
task_id: The task's unique identifier.
|
|
136
|
+
db: Injected database session.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Unified success response with updated task info.
|
|
140
|
+
"""
|
|
141
|
+
task = await get_task(db, task_id)
|
|
142
|
+
if not task:
|
|
143
|
+
raise NotFoundError("任务")
|
|
144
|
+
|
|
145
|
+
terminal_states = {TaskStatus.SUCCESS, TaskStatus.FAILED, TaskStatus.CANCELED}
|
|
146
|
+
if task.status in terminal_states:
|
|
147
|
+
raise BusinessError(f"任务已结束, 状态为 {task.status.value}", code="TASK_ALREADY_FINISHED")
|
|
148
|
+
|
|
149
|
+
# Set cancel flag in Redis with TTL
|
|
150
|
+
arq_pool = await pool.get_pool()
|
|
151
|
+
await arq_pool.set(f"task_cancel:{task_id}", b"1", ex=7200)
|
|
152
|
+
|
|
153
|
+
return success(data=TaskResponse.model_validate(task).model_dump(mode="json"))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@task_router.post("/{task_id}/retry", summary="重试任务")
|
|
157
|
+
async def retry_task(
|
|
158
|
+
task_id: str,
|
|
159
|
+
db: AsyncSession = Depends(_db_dependency),
|
|
160
|
+
) -> dict:
|
|
161
|
+
"""Retry a failed or canceled task.
|
|
162
|
+
|
|
163
|
+
Resets status to PENDING, clears result, resets progress, and re-enqueues to ARQ.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
task_id: The task's unique identifier.
|
|
167
|
+
db: Injected database session.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Unified success response with updated task info.
|
|
171
|
+
"""
|
|
172
|
+
task = await get_task(db, task_id)
|
|
173
|
+
if not task:
|
|
174
|
+
raise NotFoundError("任务")
|
|
175
|
+
|
|
176
|
+
retryable_states = {TaskStatus.FAILED, TaskStatus.CANCELED}
|
|
177
|
+
if task.status not in retryable_states:
|
|
178
|
+
raise BusinessError(f"只有失败或已取消的任务可以重试, 当前状态为 {task.status.value}", code="TASK_NOT_RETRYABLE")
|
|
179
|
+
|
|
180
|
+
# Clear cancel flag from Redis
|
|
181
|
+
arq_pool = await pool.get_pool()
|
|
182
|
+
await arq_pool.delete(f"task_cancel:{task_id}")
|
|
183
|
+
|
|
184
|
+
# Reset task state
|
|
185
|
+
await update_task_status(db, task_id, TaskStatus.PENDING)
|
|
186
|
+
await update_task_progress(db, task_id, 0)
|
|
187
|
+
await append_task_log(db, task_id, "任务重试, 重新入队")
|
|
188
|
+
task.result = None
|
|
189
|
+
await db.commit()
|
|
190
|
+
|
|
191
|
+
# Re-enqueue
|
|
192
|
+
await arq_pool.enqueue_job("_task_executor", task.id, task.task_type, task.params)
|
|
193
|
+
|
|
194
|
+
# Refresh to get latest state
|
|
195
|
+
await db.refresh(task)
|
|
196
|
+
return success(data=TaskResponse.model_validate(task).model_dump(mode="json"))
|