Enhance the reliability and observability of the task execution engine by introducing retry mechanisms, idempotency, and structured progress tracking. - Implement centralized retry logic with exponential backoff support in `JobLifecycle`. - Add `retry_task` API endpoint and `TaskManager` method for manual task restarts. - Introduce task idempotency using `_idempotency_key` to prevent duplicate executions. - Add `retry_count`, `max_retries`, `last_error`, and `progress` fields to the `Task` model and ensure persistence via `TaskPersistenceService`. - Upgrade `SchedulerService` to use differential synchronization with the persistent `SQLAlchemyJobStore` for better job durability. - Implement structured heartbeat logging to support real-time progress updates. - Update project documentation and ADRs to reflect the new plugin runtime and task resilience patterns. - Add comprehensive unit and integration tests for the new task lifecycle features.
14 KiB
Что лучшее мы можем взять из архитектуры других систем
Дата: 2026-07-10
Контекст: Следствие сравнения docs/scheduler-worker-orthogonal-comparison.md.
Цель: Конкретные, применимые улучшения для нашего in-process APScheduler + TaskManager, сохраняя наши сильные стороны (AWAITING_* состояния, TaskContext, реал-тайм UI, простота деплоя).
Мы не хотим превращаться в Celery. Мы хотим выборочно заимствовать зрелые паттерны, которые решают реальные боли нашего текущего решения.
Принципы заимствования
- Сохраняем один backend-процесс как основную модель (пока нагрузка позволяет).
- Сохраняем интерактивные состояния (AWAITING_INPUT / AWAITING_MAPPING) — это наше конкурентное преимущество.
- Добавляем обязательную отказоустойчивость и эргономику без обязательного брокера.
- Всё должно быть опциональным / расширяемым через плагины.
- Используем то, что уже есть (Postgres, tenacity в отдельных местах, bounded executors).
Приоритетные улучшения (от высокого к низкому)
1. Persistent JobStore для APScheduler (APScheduler best practice)
Откуда: Официальная документация APScheduler, рекомендации по production.
Проблема сейчас:
load_schedules()полностью пересоздаёт все jobs при каждом старте.- При сбоях между сохранением расписания в БД и регистрацией в APScheduler — рассинхрон.
- Нет нативной защиты от дубликатов при быстром рестарте.
Что взять:
- Настроить
BackgroundScheduler(jobstores=..., executors=...)сSQLAlchemyJobStore. - Использовать нашу существующую БД (или отдельную таблицу
apscheduler_jobs).
Как интегрировать:
- В
SchedulerService.__init__:from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore jobstores = { 'default': SQLAlchemyJobStore(url=..., engine=..., tablename='apscheduler_jobs') } self.scheduler = BackgroundScheduler(jobstores=jobstores) - Для динамических расписаний (translate, validation) оставить текущий механизм регистрации/удаления (они живут в прикладных таблицах).
- Для статических/конфиг-расписаний (backup) — можно положиться на jobstore.
Выгода: Расписания переживают рестарт "из коробки". Меньше кастомного кода восстановления.
Риски: Нужно аккуратно работать с pickle (или использовать job_defaults с coalesce и misfire_grace_time).
2. Централизованная политика ретраев на уровне TaskManager (Dramatiq middleware + Celery retry + tenacity)
Откуда: Dramatiq (middleware pipeline), Celery (self.retry()), tenacity (уже используется в translate/git/llm).
Проблема сейчас:
- Ретраи реализованы только внутри доменных оркестраторов (translate) или через
@retryв отдельных местах. - В
JobLifecycle._run_task— голый try/except → сразу FAILED. - Пользователь не видит "попытка 2/5", нет экспоненциального бэкоффа на уровне задачи.
Что взять:
- Добавить в
Taskполя:retry_count,max_retries,retry_policy. - Ввести исключения:
class RetryableTaskError(Exception): ... class PermanentTaskError(Exception): ... - Опциональная обёртка в
_run_task:from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type - Плагин может декларировать политику или поднимать
RetryableTaskError(delay=30). - TaskContext получить метод
request_retry(after=..., reason=...).
Интеграция:
- Расширить
models.pyTask. - Добавить retry-логику в
lifecycle.py(сохранять прогресс между попытками). - Позволить плагинам переопределять поведение.
Выгода: Единообразные ретраи для всех плагинов (backup, migration, llm-документация и т.д.). Меньше дублирования кода.
3. Heartbeat + обобщённый reconciliation stuck-задач (наш собственный паттерн + Celery worker recovery)
Откуда: Наш текущий код в app.py (очистка stuck ValidationRun) + типичная практика очередей.
Проблема:
- При падении backend RUNNING задачи остаются в статусе RUNNING навсегда.
- Есть только узкая очистка для ValidationRun.
Что взять:
- Добавить в
TaskRecord/Taskполеlast_heartbeat_at. - В
TaskContext:async def heartbeat(self, progress: float | None = None): ... - На старте (в lifespan) — универсальный reconcilier:
- Найти все
status=RUNNINGбез живого_async_task. - Если
last_heartbeat_atстарый (> N минут) → пометитьINTERRUPTED/FAILEDс причиной.
- Найти все
- Периодический "watchdog" (отдельная APS job или фоновая задача).
Выгода: Задачи не висят в "зомби"-состоянии. Пользователь сразу видит, что задача была прервана рестартом.
4. Graceful shutdown и draining задач (Celery worker shutdown + современные async приложения)
Откуда: Celery (graceful shutdown, -Ofair, draining), FastAPI/Starlette lifespan best practices, ARQ.
Проблема сейчас:
- В
lifespanshutdown толькоscheduler.stop(). _async_tasksне ждут и не отменяются контролируемо.- Длинные задачи (часовые переводы) обрываются жёстко.
Что взять:
- В shutdown-части lifespan:
async def shutdown(): scheduler.stop() # Дать задачам шанс завершиться tasks = list(task_manager._async_tasks.values()) if tasks: done, pending = await asyncio.wait(tasks, timeout=graceful_timeout) for t in pending: t.cancel() await asyncio.gather(*pending, return_exceptions=True) # flush remaining logs - Добавить в Task статусы
CANCELLING,INTERRUPTED. - Плагины могут реагировать на
asyncio.CancelledErrorи делать cleanup.
Конфиг: Уже упоминается graceful_shutdown_timeout в ADR-0011 — реализовать.
5. Structured progress + лёгкие обновления (ARQ + наши логи)
Откуда: ARQ имеет job.progress(...), Celery имеет task.update_state.
Что взять:
- Расширить
TaskContext:def set_progress(self, percent: float, message: str | None = None, metadata: dict | None = None): ... # обновить task + persist + broadcast (лёгкое событие, не только через логи) - Добавить в Task модель поле
progress: float | None. - UI может показывать прогресс-бар поверх логов.
Выгода: Меньше спама в логах для длинных задач + лучший UX в Task Status Center.
6. Лёгкая композиция задач / chaining (Celery canvas в упрощённом виде)
Откуда: Celery (chain, group, chord), Dramatiq pipelines.
Что взять (минимально):
- В
TaskContextдобавить:async def schedule_next(self, plugin_id: str, params: dict, when: "now" | timedelta = "now"): ... - Или поддержка "continuation token" — после успеха текущей задачи автоматически запустить следующую (если указано в params).
- Для translate уже есть похожая логика (preview → full run) — формализовать.
Не брать полностью сложный canvas, пока не появится реальная потребность в DAG'ах задач.
7. Idempotency keys + защита от дубликатов (многие системы)
Польза:
- При регистрации расписания или при быстром рестарте не создавать дублирующиеся задачи.
- Ключ:
f"{plugin_id}:{hash(params)}:{window}"или явныйidempotency_keyот вызывающего.
Интеграция: в create_task проверять недавние похожие задачи.
8. Middleware-подобный pipeline для TaskLifecycle (Dramatiq)
Идея (более продвинутая):
- Вместо монолитного
_run_task— цепочка middleware:- logging
- retry
- timeout
- metrics
- persistence
- Каждый плагин/тип задачи может регистрировать свой middleware.
Это даёт чистоту, но требует больше архитектурной работы.
Что НЕ стоит брать (пока)
- Полноценный внешний брокер (Redis/Rabbit) — сильно увеличивает операционную сложность. Рассматривать только при доказанной необходимости горизонтального масштаба > 1-2 backend'ов.
- Полноценные workflow-оркестраторы (Prefect, Temporal, Airflow) — избыточно для нашей модели плагинов.
- Жёсткое разделение producer/consumer — мы выигрываем от того, что задача выполняется в контексте приложения (доступ к тем же сервисам, БД, конфигу).
Рекомендуемый порядок внедрения
- ✅ Persistent JobStore + дифференциальная загрузка + reconciliation stuck задач — реализовано.
- ✅ Graceful shutdown + draining — реализовано.
- ✅ Heartbeat + базовый structured progress (Task.progress + context.heartbeat) — реализовано.
- ✅ Централизованная retry-логика (loop + backoff + Task поля + автоматический retry) — реализовано.
- ✅ Retry endpoint
POST /tasks/{id}/retry+ TaskManager.retry_task — реализовано. - ✅ Идемпотентность — базовая через _idempotency_key.
- Дальше: полноценное хранение retry/progress в БД TaskRecord, chaining (schedule_followup), тесты, фронтенд отображение retry_count/progress.
Все изменения сделаны с сохранением обратной совместимости и семантики проекта.
Последние доработки:
POST /api/tasks/{task_id}/retry+ полная поддержка в TaskManager/JobLifecycle- Автоматические ретраи задач через
_max_retries/_retry_policyв params - Поля
retry_count,max_retries,progress,last_errorв Task (сериализуются в API/WS) - Дифференциальная синхронизация расписаний (лучше использует persistent jobstore)
- Базовая идемпотентность по
_idempotency_key
Следующие шаги
- Создать ADR для "Task Execution Resilience".
- Добавить dedicated колонки в TaskRecord (или улучшить JSON persistence).
- Реализовать chaining (context.schedule_followup) и тесты.
- Обновить фронтенд Task Status Center для отображения retry/progress.
Эти улучшения позволят нам взять лучшее (надёжность Celery/Dramatiq/ARQ + удобство persistent scheduling из APScheduler) без потери нашей уникальной интерактивности и простоты.
Полный контекст сравнения — в docs/scheduler-worker-orthogonal-comparison.md.