Skip to content

Commit 49edf40

Browse files
committed
feat(redis): restore redis consumers and taskiq
1 parent bcb183c commit 49edf40

31 files changed

Lines changed: 1033 additions & 129 deletions

docs/migration-traceability.toml

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,3 +313,176 @@ allowed_transformations = ["删除 owner 外键与 index", "重置为初始 migr
313313
removed_references = ["owner_id、users.id ForeignKey、ix_items_owner_id、legacy down_revision"]
314314
approved_deviation = ["保留 revision 0021_create_items 身份且 down_revision=None"]
315315
verification = ["test_item_crud_switch_controls_the_complete_generated_slice", "test_both_orms_pass_real_postgresql_contract"]
316+
317+
# Issue #9 Redis consumers 与 Taskiq 独立 profile。
318+
319+
[[target]]
320+
profile = "redis_consumers"
321+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/pyproject.toml"
322+
target = "pyproject.toml"
323+
allowed_transformations = ["Copier 模板语法", "仅保留 Redis、cache 与 rate limit 依赖", "依赖版本兼容修改"]
324+
removed_references = ["V1 排除能力依赖"]
325+
approved_deviation = ["Logfire 关闭时不生成 instrumentation extras"]
326+
verification = ["test_redis_consumers_pass_real_redis_contract"]
327+
328+
[[target]]
329+
profile = "redis_consumers"
330+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/.env.example"
331+
target = ".env.example"
332+
allowed_transformations = ["Copier 模板语法", "仅保留启用 consumer 的环境变量"]
333+
removed_references = ["V1 排除能力与 Taskiq 变量"]
334+
approved_deviation = ["RATE_LIMIT_STORAGE 显式记录 redis"]
335+
verification = ["test_issue_9_generation_boundaries"]
336+
337+
[[target]]
338+
profile = "redis_consumers"
339+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/app/main.py"
340+
target = "app/main.py"
341+
allowed_transformations = ["删除 V1 排除生命周期", "保留 Redis、cache 与 limiter 生命周期"]
342+
removed_references = ["用户、RAG、消息渠道、admin 与其他 V1 排除能力"]
343+
approved_deviation = ["FastAPICache shutdown 显式 reset", "SlowAPI 使用 ASGI middleware 与统一异常 envelope"]
344+
verification = ["test_redis_consumers_pass_real_redis_contract"]
345+
346+
[[target]]
347+
profile = "redis_consumers"
348+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/app/core/config.py"
349+
target = "app/core/config.py"
350+
allowed_transformations = ["删除 V1 排除 Settings", "保留 Redis 与 limiter Settings"]
351+
removed_references = ["身份、billing、文件、RAG 与 provider Settings"]
352+
approved_deviation = ["RATE_LIMIT_STORAGE 作为显式 Settings"]
353+
verification = ["test_issue_9_generation_boundaries"]
354+
355+
[[target]]
356+
profile = "redis_consumers"
357+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/app/clients/redis.py"
358+
target = "app/clients/redis.py"
359+
allowed_transformations = ["Copier 模板语法", "当前 redis asyncio close API"]
360+
removed_references = [""]
361+
approved_deviation = ["raw client 保持 binary 供 FastAPICache 使用,get 执行 UTF-8 decode"]
362+
verification = ["test_redis_consumers_pass_real_redis_contract"]
363+
364+
[[target]]
365+
profile = "redis_consumers"
366+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/app/core/cache.py"
367+
target = "app/core/cache.py"
368+
allowed_transformations = ["Copier 模板语法", "增加 shutdown reset"]
369+
removed_references = ["disabled no-op 分支"]
370+
approved_deviation = ["文件仅在启用 cache 时生成"]
371+
verification = ["test_redis_consumers_pass_real_redis_contract"]
372+
373+
[[target]]
374+
profile = "redis_consumers"
375+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/app/core/rate_limit.py"
376+
target = "app/core/rate_limit.py"
377+
allowed_transformations = ["Copier 模板语法", "增加 Redis storage URI", "保留 low/medium/high decorators"]
378+
removed_references = ["disabled 空模块"]
379+
approved_deviation = ["Redis rate limit 使用 settings.REDIS_URL 共享服务端状态"]
380+
verification = ["test_redis_consumers_pass_real_redis_contract"]
381+
382+
[[target]]
383+
profile = "redis_consumers"
384+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/app/api/deps.py"
385+
target = "app/api/deps.py"
386+
allowed_transformations = ["删除 V1 排除 dependencies", "deployment API Key 替换", "保留 Redis dependency"]
387+
removed_references = ["用户、JWT、teams 与排除 service dependencies"]
388+
approved_deviation = ["Redis client 从 app lifespan state 注入"]
389+
verification = ["test_redis_consumers_pass_real_redis_contract"]
390+
391+
[[target]]
392+
profile = "redis_consumers"
393+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/health.py"
394+
target = "app/api/health.py"
395+
allowed_transformations = ["根路径迁移", "删除排除资源 probes", "保留 Redis readiness"]
396+
removed_references = ["RAG、provider、billing、Celery probes"]
397+
approved_deviation = ["Redis probe 使用 dependency-injected RedisClient"]
398+
verification = ["test_redis_consumers_pass_real_redis_contract"]
399+
400+
[[target]]
401+
profile = "rate_limit_memory"
402+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/pyproject.toml"
403+
target = "pyproject.toml"
404+
allowed_transformations = ["Copier 模板语法", "仅保留 SlowAPI 依赖"]
405+
removed_references = ["V1 排除能力与 Redis 依赖"]
406+
approved_deviation = [""]
407+
verification = ["test_memory_rate_limit_uses_no_redis"]
408+
409+
[[target]]
410+
profile = "rate_limit_memory"
411+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/.env.example"
412+
target = ".env.example"
413+
allowed_transformations = ["Copier 模板语法", "仅保留 limiter 环境变量"]
414+
removed_references = ["V1 排除能力与 Redis 变量"]
415+
approved_deviation = ["RATE_LIMIT_STORAGE 显式记录 memory"]
416+
verification = ["test_memory_rate_limit_uses_no_redis"]
417+
418+
[[target]]
419+
profile = "rate_limit_memory"
420+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/app/core/config.py"
421+
target = "app/core/config.py"
422+
allowed_transformations = ["删除 V1 排除 Settings", "保留 limiter Settings"]
423+
removed_references = ["Redis 与其他未启用能力 Settings"]
424+
approved_deviation = ["RATE_LIMIT_STORAGE 作为显式 Settings"]
425+
verification = ["test_memory_rate_limit_uses_no_redis"]
426+
427+
[[target]]
428+
profile = "rate_limit_memory"
429+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/app/core/rate_limit.py"
430+
target = "app/core/rate_limit.py"
431+
allowed_transformations = ["Copier 模板语法", "显式 memory storage URI", "保留 low/medium/high decorators"]
432+
removed_references = ["disabled 空模块"]
433+
approved_deviation = ["memory:// 明确限制为 process-local storage"]
434+
verification = ["test_memory_rate_limit_uses_no_redis"]
435+
436+
[[target]]
437+
profile = "rate_limit_memory"
438+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/app/main.py"
439+
target = "app/main.py"
440+
allowed_transformations = ["删除 V1 排除生命周期", "保留 SlowAPI middleware"]
441+
removed_references = ["Redis 与其他未启用资源生命周期"]
442+
approved_deviation = ["使用 SlowAPIASGIMiddleware 与统一异常 envelope"]
443+
verification = ["test_memory_rate_limit_uses_no_redis"]
444+
445+
[[target]]
446+
profile = "taskiq"
447+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/pyproject.toml"
448+
target = "pyproject.toml"
449+
allowed_transformations = ["Copier 模板语法", "仅保留 Taskiq 与 taskiq-redis 依赖", "依赖版本兼容修改"]
450+
removed_references = ["V1 排除 queue 与 API Redis client 依赖"]
451+
approved_deviation = [""]
452+
verification = ["test_taskiq_process_commands_start_and_stop_against_real_redis"]
453+
454+
[[target]]
455+
profile = "taskiq"
456+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/.env.example"
457+
target = ".env.example"
458+
allowed_transformations = ["Copier 模板语法", "仅保留 Taskiq 环境变量"]
459+
removed_references = ["V1 排除能力与 API Redis client 变量"]
460+
approved_deviation = ["result backend 变量命名为 TASKIQ_RESULT_BACKEND_URL"]
461+
verification = ["test_issue_9_generation_boundaries"]
462+
463+
[[target]]
464+
profile = "taskiq"
465+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/app/core/config.py"
466+
target = "app/core/config.py"
467+
allowed_transformations = ["删除 V1 排除 Settings", "保留独立 Taskiq URLs"]
468+
removed_references = ["API Redis client 与其他未启用能力 Settings"]
469+
approved_deviation = ["result backend 变量命名为 TASKIQ_RESULT_BACKEND_URL"]
470+
verification = ["test_issue_9_generation_boundaries"]
471+
472+
[[target]]
473+
profile = "taskiq"
474+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/app/worker/taskiq_app.py"
475+
target = "app/worker/taskiq_app.py"
476+
allowed_transformations = ["Copier 模板语法", "删除 legacy schedule import", "startup 与 shutdown 执行 Redis health"]
477+
removed_references = ["RAG schedule 与虚构业务 task"]
478+
approved_deviation = ["生命周期 hooks 同时验证 broker 与 result backend,pool 关闭仍由 Taskiq owner 管理"]
479+
verification = ["test_taskiq_process_commands_start_and_stop_against_real_redis"]
480+
481+
[[target]]
482+
profile = "taskiq"
483+
legacy_source = "legacy/template/{{cookiecutter.project_slug}}/backend/cli/commands.py"
484+
target = "cli/commands.py"
485+
allowed_transformations = ["删除 V1 排除 commands", "保留 server 与 Taskiq commands"]
486+
removed_references = ["用户、Celery、database 与 custom commands"]
487+
approved_deviation = ["subprocess.run 使用 check=True"]
488+
verification = ["test_taskiq_process_commands_start_and_stop_against_real_redis"]

template/.env.example.jinja

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ DB_POOL_SIZE=[[ db_pool_size ]]
1616
DB_MAX_OVERFLOW=[[ db_max_overflow ]]
1717
DB_POOL_TIMEOUT=[[ db_pool_timeout ]]
1818
[% endif %]
19+
[% if background_tasks == "taskiq" %]
20+
TASKIQ_BROKER_URL=redis://localhost:6379/1
21+
TASKIQ_RESULT_BACKEND_URL=redis://localhost:6379/1
22+
[% endif %]
1923
[% if enable_redis %]
2024
REDIS_HOST=localhost
2125
REDIS_PORT=6379

template/AGENTS.md.jinja

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
- `alembic/`:PostgreSQL migration;通过 `uv run [[ project_slug ]] db ...` 管理。
1515
[% endif %][% if database == "postgresql" and include_example_crud %]- `app/db/models/item.py` → `app/repositories/item.py` → `app/services/item.py` → `app/api/items.py`:可选 Item CRUD。
1616
[% endif %]
17+
[% if enable_redis %]- `app/clients/redis.py`:API lifespan 拥有的 async Redis client;通过 `app.api.deps.Redis` 注入。
18+
[% endif %][% if enable_caching %]- `app/core/cache.py`:复用 API Redis client 的 FastAPICache backend。
19+
[% endif %][% if enable_rate_limiting %]- `app/core/rate_limit.py`:SlowAPI limiter 与 low/medium/high decorators。
20+
[% endif %][% if background_tasks == "taskiq" %]- `app/worker/taskiq_app.py`:独立 Taskiq broker、result backend 与 scheduler。
21+
[% endif %]
1722

1823
```bash
1924
uv sync --all-groups
@@ -36,6 +41,12 @@ uv run pytest
3641
- migration 使用 `uv run [[ project_slug ]] db upgrade` 执行;readiness 通过数据库 session 执行 `SELECT 1`。
3742
[% endif %][% if database == "postgresql" and include_example_crud %]- Item 仅提供 create/get/update/delete;不添加 owner/user 外键、list endpoint 或分页 schema。
3843
[% endif %]
44+
[% if enable_redis %]- Redis client 必须由 API lifespan `connect()`/`close()`;readiness 通过注入的 client 执行 `ping()`。
45+
[% endif %][% if enable_caching %]- FastAPICache 与 Redis client 共享 binary connection;shutdown 时调用 `FastAPICache.reset()`。
46+
[% endif %][% if enable_rate_limiting %]- Rate limiting 使用 [[ "Redis shared storage" if rate_limit_storage == "redis" else "process-local memory storage" ]];业务 route 按成本选择 `rate_limit_low`、`rate_limit_medium` 或 `rate_limit_high`。
47+
[% endif %][% if background_tasks == "taskiq" %]- Taskiq 只使用 `TASKIQ_BROKER_URL` 与 `TASKIQ_RESULT_BACKEND_URL`,不复用 API Redis client 生命周期。
48+
- 使用 `uv run [[ project_slug ]] taskiq worker` 和 `uv run [[ project_slug ]] taskiq scheduler` 启动进程;业务 task 在调用方模块中通过 `broker.task` 定义。
49+
[% endif %]
3950
## 约束
4051

4152
- I/O 和生命周期路径使用 async。

template/README.md.jinja

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,27 @@ uv run [[ project_slug ]] db current
2626

2727
Item 示例 API:`POST /api/v1/items`、`GET /api/v1/items/{item_id}`、`PATCH /api/v1/items/{item_id}`、`DELETE /api/v1/items/{item_id}`。
2828
[% endif %][% endif %]
29+
[% if enable_redis %]
30+
31+
## Redis
32+
33+
配置 `REDIS_HOST`、`REDIS_PORT`、`REDIS_PASSWORD` 和 `REDIS_DB`。API lifespan 管理连接,`/health/ready` 验证 Redis。
34+
[% endif %][% if enable_caching %]
35+
36+
FastAPICache 使用相同的 API Redis connection,并以 `[[ project_slug ]]:cache:` 为 key prefix。
37+
[% endif %][% if enable_rate_limiting %]
38+
39+
## Rate limiting
40+
41+
SlowAPI 默认限制为 `RATE_LIMIT_REQUESTS` / `RATE_LIMIT_PERIOD`,storage 为 `[[ rate_limit_storage ]]`。业务 route 使用 `rate_limit_low`、`rate_limit_medium` 或 `rate_limit_high` decorator。
42+
[% endif %][% if background_tasks == "taskiq" %]
43+
44+
## Taskiq
45+
46+
Taskiq 使用独立的 `TASKIQ_BROKER_URL` 与 `TASKIQ_RESULT_BACKEND_URL`:
47+
48+
```bash
49+
uv run [[ project_slug ]] taskiq worker
50+
uv run [[ project_slug ]] taskiq scheduler
51+
```
52+
[% endif %]

template/[% if ci_type == 'github' %].github[% endif %]/workflows/ci.yml.jinja

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on:
77
jobs:
88
quality:
99
runs-on: ubuntu-latest
10-
[% if database == "postgresql" or enable_redis %] services:
10+
[% if database == "postgresql" or enable_redis or background_tasks == "taskiq" %] services:
1111
[% if database == "postgresql" %] postgres:
1212
image: postgres:17-alpine
1313
env:
@@ -21,7 +21,7 @@ jobs:
2121
--health-interval 2s
2222
--health-timeout 3s
2323
--health-retries 15
24-
[% endif %][% if enable_redis %] redis:
24+
[% endif %][% if enable_redis or background_tasks == "taskiq" %] redis:
2525
image: redis:8-alpine
2626
ports:
2727
- 6379:6379
@@ -38,6 +38,8 @@ jobs:
3838
POSTGRES_DB: app
3939
[% endif %][% if enable_redis %] REDIS_HOST: 127.0.0.1
4040
REDIS_PORT: 6379
41+
[% endif %][% if background_tasks == "taskiq" %] TASKIQ_BROKER_URL: redis://127.0.0.1:6379/1
42+
TASKIQ_RESULT_BACKEND_URL: redis://127.0.0.1:6379/1
4143
[% endif %][% if enable_logfire %] LOGFIRE_SEND_TO_LOGFIRE: "false"
4244
[% endif %] steps:
4345
- uses: actions/checkout@v4

template/[% if enable_docker %]deploy[% endif %]/compose.yaml.jinja

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ services:
66
environment:
77
API_KEY: "${API_KEY:-[[ deployment_api_key ]]}"
88
[% if database == "postgresql" %] DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/app
9-
[% endif %][% if enable_redis %] REDIS_URL: redis://redis:6379/0
9+
[% endif %][% if enable_redis %] REDIS_HOST: redis
10+
REDIS_PORT: 6379
11+
[% endif %][% if background_tasks == "taskiq" %] TASKIQ_BROKER_URL: redis://redis:6379/1
12+
TASKIQ_RESULT_BACKEND_URL: redis://redis:6379/1
1013
[% endif %][% if ai_framework == "pydantic_ai" %] LLM_BASE_URL: "${LLM_BASE_URL:-https://api.example.com/v1}"
1114
LLM_API_KEY: "${LLM_API_KEY:-model-key-CHANGEME}"
1215
LLM_MODEL: "${LLM_MODEL:-model-name-CHANGEME}"
@@ -28,9 +31,10 @@ services:
2831
build:
2932
context: ..
3033
dockerfile: deploy/Dockerfile
31-
command: ["uv", "run", "--no-sync", "taskiq", "worker", "app.tasks.taskiq:broker"]
34+
command: ["uv", "run", "--no-sync", "taskiq", "worker", "app.worker.taskiq_app:broker"]
3235
environment:
33-
REDIS_URL: redis://redis:6379/0
36+
TASKIQ_BROKER_URL: redis://redis:6379/1
37+
TASKIQ_RESULT_BACKEND_URL: redis://redis:6379/1
3438
[% if enable_logfire %] LOGFIRE_TOKEN: "${LOGFIRE_TOKEN:-}"
3539
[% endif %]
3640
depends_on:
@@ -41,9 +45,10 @@ services:
4145
build:
4246
context: ..
4347
dockerfile: deploy/Dockerfile
44-
command: ["uv", "run", "--no-sync", "taskiq", "scheduler", "app.tasks.taskiq:scheduler"]
48+
command: ["uv", "run", "--no-sync", "taskiq", "scheduler", "app.worker.taskiq_app:scheduler"]
4549
environment:
46-
REDIS_URL: redis://redis:6379/0
50+
TASKIQ_BROKER_URL: redis://redis:6379/1
51+
TASKIQ_RESULT_BACKEND_URL: redis://redis:6379/1
4752
[% if enable_logfire %] LOGFIRE_TOKEN: "${LOGFIRE_TOKEN:-}"
4853
[% endif %]
4954
depends_on:

template/app/[% if background_tasks == 'taskiq' %]tasks[% endif %]/__init__.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

template/app/[% if background_tasks == 'taskiq' %]tasks[% endif %]/taskiq.py.jinja

Lines changed: 0 additions & 33 deletions
This file was deleted.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Taskiq worker process configuration."""
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
from redis.asyncio import Redis
4+
from taskiq import TaskiqEvents, TaskiqScheduler, TaskiqState
5+
from taskiq.schedule_sources import LabelScheduleSource
6+
from taskiq_redis import ListQueueBroker, RedisAsyncResultBackend
7+
8+
from app.core.config import settings
9+
[% if enable_logfire %]from app.core.telemetry import configure_telemetry
10+
[% endif -%]
11+
[[ "\n" -]]
12+
result_backend = RedisAsyncResultBackend(redis_url=settings.TASKIQ_RESULT_BACKEND_URL)
13+
broker = ListQueueBroker(url=settings.TASKIQ_BROKER_URL).with_result_backend(result_backend)
14+
scheduler = TaskiqScheduler(broker=broker, sources=[LabelScheduleSource(broker)])
15+
16+
17+
async def ping_redis_consumers() -> None:
18+
"""Fail when either Taskiq Redis consumer is unhealthy."""
19+
async with Redis(connection_pool=broker.connection_pool) as broker_redis:
20+
await broker_redis.ping()
21+
async with Redis(connection_pool=result_backend.redis_pool) as result_redis:
22+
await result_redis.ping()
23+
24+
25+
@broker.on_event(TaskiqEvents.CLIENT_STARTUP, TaskiqEvents.WORKER_STARTUP)
26+
async def startup(_state: TaskiqState) -> None:
27+
"""Configure integrations and validate Taskiq Redis connections."""
28+
[% if enable_logfire %] configure_telemetry()
29+
[% endif %] await ping_redis_consumers()
30+
31+
32+
@broker.on_event(TaskiqEvents.CLIENT_SHUTDOWN, TaskiqEvents.WORKER_SHUTDOWN)
33+
async def shutdown(_state: TaskiqState) -> None:
34+
"""Validate Redis before Taskiq closes its broker resources."""
35+
await ping_redis_consumers()

0 commit comments

Comments
 (0)