Files
ss-tools/docs/improvements-inspired-by-alternatives.md
busya 24d3b7d1f9 refactor(task-manager): implement task resilience and execution lifecycle improvements
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.
2026-07-12 15:31:56 +03:00

14 KiB
Raw Blame History

Что лучшее мы можем взять из архитектуры других систем

Дата: 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.py Task.
  • Добавить 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.

Проблема сейчас:

  • В lifespan shutdown только 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 — мы выигрываем от того, что задача выполняется в контексте приложения (доступ к тем же сервисам, БД, конфигу).

Рекомендуемый порядок внедрения

  1. ✅ Persistent JobStore + дифференциальная загрузка + reconciliation stuck задач — реализовано.
  2. ✅ Graceful shutdown + draining — реализовано.
  3. ✅ Heartbeat + базовый structured progress (Task.progress + context.heartbeat) — реализовано.
  4. ✅ Централизованная retry-логика (loop + backoff + Task поля + автоматический retry) — реализовано.
  5. ✅ Retry endpoint POST /tasks/{id}/retry + TaskManager.retry_task — реализовано.
  6. ✅ Идемпотентность — базовая через _idempotency_key.
  7. Дальше: полноценное хранение 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.