Автоматизація задач у канбані
Контракт між Celery-воркером і канбаном — CAS у хендлері, відкат при збої enqueue, живі оновлення дошки.
Доповнює Статуси задач та блокування полів, який описує сам флоу статусів. Тут — тільки те, що робить перехід у Ready for Production особливим, і які наслідки це має для бекенду й дошки. ⚠️ Автоматизація стосується лише задач з
Work Stream = Performance; для інших work stream хендлер лише ставитьautomation.state = okі виходить.
1. У чому суть
Ready for Production — єдиний статус, вхід у який запускає бота. Перехід сюди комітиться
одразу, а Celery-хендлер працює вже після нього: задача стоїть у Ready for Production з
automation.state = pending, і саме ця пара — а не сам статус — означає «прогон іде».
Окремого статусу automation більше немає. Він був транзитним — задача висіла в ньому рівно доки
працював хендлер, і жодного стану, який хтось міг би прочитати як осмислений, не позначав.
Код: kumquat_api/routes/tasks.py (transition_task_status),
kumquat_api/worker/tasks.py (run_task_transition),
packages/kumquat-tasks/.../workflow/handlers.py (run_automation).
Наслідок, з якого випливає все решта: відповідь на PATCH уже застаріла в момент, коли клієнт її отримав. Дошка не має права вважати, що картка лишиться там, куди її кинули.
2. Правила для бекенду
2.1 Відкат у Backlog — compare-and-set
Користувач може перетягнути картку з Ready for Production кудись далі, поки хендлер працює.
Хендлер тримає в пам’яті знімок задачі, прочитаний до цього, тож безумовний $set мовчки
затирав би рішення користувача — це втрата даних, а не косметика.
Тому _revert() пише статус через TasksRepository.update_if_status(...) проти
AUTOMATION_STATUS. Якщо CAS не пройшов — logger.warning і вихід, статус не чіпаємо.
Успішний прогон статус не пише взагалі: задача вже в тому статусі, у який її мав би
перевести старий _advance(). Це не спрощення, а посилення — картку, яку користувач устиг
перетягнути далі, успіх більше не має чим смикнути назад.
2.2 Артефакти пишуться безумовно
Drive-папка вже створена фізично, а план назв — це те, за чим дизайнер називатиме файли. Якщо не
записати generated_name / planned_creative_names / drive_url / drive_folder_id, вони
осиротіють. Тому запис розділено:
| Що | Як пишеться |
|---|---|
generated_name, planned_creative_names, drive_url, drive_folder_id, automation.* |
безумовно (update) |
status (тільки відкат у backlog) |
CAS проти AUTOMATION_STATUS (update_if_status) |
Тобто в _revert(): текст помилки й очищення плану записуються завжди, повернення в backlog —
тільки CAS.
2.3 Креативи створює Done, а не Ready for Production
Прогон Ready for Production карток не робить — він робить те, що потрібне самому виробництву:
фіксує теги, планує назви (plan_creative_names → Task.planned_creative_names) і створює папку.
Картки з цього плану мінтить перехід у Done.
Наслідок для відкату: у провального прогону більше нема чого прибирати. Раніше _fail() мусив
purge-ити щойно вставлені креативи, а _clear_orphan_cards() — розрізняти «сміття недороблного
прогону» і «чиюсь роботу». Тепер картка на задачі означає рівно одне: задача колись доходила до
Done. Тому _refuse_materialized_cards() відмовляє завжди, незалежно від generated_name, а
_is_redelivery() читається лише з generated_name + automation.state.
2.3.1 Перехід у Done вимагає файлів, які задача запланувала
Перехід у Done відхиляється (409), поки хоча б одна назва з
Task.planned_creative_names не має файлу в Drive-папці задачі. Перевірка синхронна — у
TasksService.transition_status, поряд з assert_ready_to_enter, до будь-якого запису: задача не
рухається, рядка історії немає, хендлер не ставиться в чергу. Повідомлення перелічує саме ті назви,
яких бракує.
Чому тут, а не в on_done: Done термінальний і мінтить картки з плану. Хендлер, який поскаржився б
постфактум, скаржиться вже на пачку карток, кожна з яких стоїть за файлом, якого ніхто не
завантажив, — і gdrive-sync ніколи не знайде для них пари.
Правило порівняння живе в kumquat_tasks/deliverables.py, читання папки — в
kumquat_api/production_folder.py (пакет задач не залежить ні від воркера, ні від Drive, тому
контракт — локальний Protocol). Два джерела, саме в такому порядку:
gdrive__filesпоfolder_id = task.drive_folder_id— одне індексоване читання, але сканер ходить раз на 5 хвилин, тож щойно завантажений файл там ще не видно.- Сам Drive — тільки про ті назви, яких не вистачило першому джерелу. На штатному шляху це нуль назв і нуль запитів у мережу.
Недоступний Drive задачу не пропускає: лишається вердикт із першого джерела, а в тексті помилки з’являється уточнення, що до Drive не достукались. Інакше будь-який збій Google тихо вимикав би перевірку — і про це стало б відомо лише з карток, які вона пропустила.
Для задачі з media_type = VID назву закриває тільки відеофайл. Фото з тим самим стемом — це
обкладинка того відео (розд. 2.3.2), тож зарахувати його означало б закрити задачу, відео для якої
ніхто не зняв. Для решти media-типів підходить будь-який файл із такою назвою.
Мовчки пропускаються задачі, яким нема з чим звірятися: не-Performance воркстріми (автоматизація
для них не працює), порожній план (задача з часів до цього поля або тип, що карток не мінтить) і
задача без drive_folder_id.
2.3.2 Фото в папці VID-задачі — це обкладинка
Сканер gdrive-sync кладе файл або в gdrive__files (креатив, який треба обробити), або в
gdrive__posters (обкладинка відео). Раніше це вирішувала лише назва файлу: зображення з токеном
Vid — обкладинка. Тепер додалося друге правило — зображення в папці задачі з
media_type = VID, хай як воно назване.
Обидва правила діють разом (Scanner._is_poster), бо відповідають за різні файли. Назва
класифікує файл сам по собі — а це якраз те, що приносить стрічка змін Drive: довільний набір
файлів, ніколи не вміст папки. Задача класифікує решту: вона попросила відео, отже зображення в її
папці — кадр до нього, як би дизайнер його не назвав. Папку задачі сканер знаходить через
task__tasks.drive_folder_id (gdrive_sync/task_folders.py), з кешем на час одного прогону;
відповідний індекс — у kumquat_tasks/indexes.py.
Зміна media_type не породжує події в Drive, тому зображення, синхронізовані до того, як
задачу позначили VID, самі нікуди не переїдуть. Для них є gdrive-sync reroute-posters
(maintenance.reroute_task_posters) — ідемпотентна, за замовчуванням dry-run. Повна resync дає
той самий результат, але переставляє курсор і перечитує всі диски.
Команда чистить і creatives.media тих карток, які показували саме це зображення (матч по
media.drive_file_id, тож блок, зібраний з відео, не чіпається). Без цього картка лишалася б із
фото назавжди: project_all_media обходить назви, які є в gdrive__files, а ця назва звідти
щойно пішла, тож до картки більше ніхто не повернувся б. Блок занулюється, а не перенаправляється
на відео: відео може бути ще не синхронізоване, і media: null — це рівно те, що проєкція вже
означає під «файлу для цієї назви немає». Наступний цикл підставить відео, щойно воно з’явиться.
Межа правила: постер стикується з відео за назвою. SyncWorker._sidecar шукає
gdrive__posters по name, ідентичній назві відео. Тип медіа задачі вирішує, що фото — не
окремий креатив (і воно зникає зі стрічки файлів задачі), але чиїм саме постером воно є, каже
назва. Фото, назване довільно, лишається постером без відео — сканер рахує такі в
posters_orphaned, а відео показує власний перший кадр. Саме цю дисципліну назв і вимагає гейт із
розд. 2.3.1: назва з плану — те, під чим має лежати відео, і те, під чим має лежати його обкладинка.
2.4 on_done мінтить картки — і не має права кидати виняток
on_done() робить три речі: створює картки з плану (_mint_creatives), ставить completed_at і
пінгує UAM, якщо стоїть Manual Launch.
Імена беруться з плану, не перебудовуються (_named_by_plan). Це не оптимізація: назва
похідної картки (LOC/WHT/ADPT/RSZ) будується з number і name оригінала, а оригінал належить
іншій задачі, яку ніхто не блокував. Якби її встигли перейменувати між Ready for Production і Done,
перебудована назва розійшлася б із тією, під якою дизайнер уже назвав файли — і gdrive-sync,
який стикує файл із карткою рівно за рядком назви, більше не знайшов би пари. План довший або
коротший за набір карток — це вже справжня розбіжність, і вона відхиляється, бо вгадувати, який із
кінців правильний, означає або осиротити файли, або створити картку, якої ніхто не називав.
Виняток назовні не випускається ніколи. Відкочувати нема чого (вставка — остання дія), відсилати
задачу нікуди, а acks_late перевидав би джобу нескінченно. Тому помилка пишеться в
automation.state = error з текстом і йде DM власнику, а задача лишається в Done — робота ж
прийнята, completed_at ставиться в будь-якому разі.
Повторний прохід (перевидана джоба, другий вхід у Done) картки не дублює: _mint_creatives
виходить одразу, якщо на задачі вже є картки. Назви унікальні, тож другий мінт усе одно не пройшов
би — але й звітувати про нього як про помилку неправильно, бо робити не було чого.
2.6 Drive: папка перейменовується, файли їдуть за нею
ensure_task_folder(product, task_name, folder_id) замінив create_task_folder. З folder_id він
робить files.update — читає поточні name/parents і пише тільки те, що справді змінилось, тож
прогон без змін не шле жодного запиту. Без folder_id — стара поведінка find-or-create.
Чому перейменування, а не нова папка: посилання Drive адресується за id, тож перейменування зберігає
кожен раніше розшарений лінк і не рухає жодного байта. addParents при зміні Product іде в парі з
removeParents — самого addParents мало, папка опинилась би у двох місцях одночасно.
_rename_files() перейменовує вміст під новий план. Пари підбирає plan_renames() — чиста функція
в drive.py, покрита tests/tasks/test_drive_renames.py:
- позиція плюс збіг «координат» картки (
{var}-{task#}префікс). Самої позиції мало: LOC-задача з підміненим Origin Creative дає ту саму кількість назв для іншого оригінала, і перейменування за позицією підписало б роботу одного оригінала іменем іншого; - різна довжина планів не парується взагалі — змінився сам набір карток;
- legacy-назва (
{ім'я оригінала}_{суфікс}) координат не має і не парується ніколи.
Усе, що не спарувалось, іде в WorkflowNotifier.files_left_behind — файл лишається зі старою назвою,
а людина бачить, які саме. Сам rename — best effort на файл: відмова одного не валить прогон, який
усе інше вже закомітив.
Наслідок для _revert(): план більше не чиститься при провалі. Він не вихід цього прогону — це
останній опублікований план, тобто єдиний запис про те, як зараз називаються файли в папці.
Почистити його означало б позбавити наступний прогон можливості їх перейменувати. Нічого це не
резервує: planned_name_claims ігнорує задачі в Backlog, куди відкат її і кладе.
2.7 Куди веде Backlog, і чому не з Done
ALLOWED_TRANSITIONS пускає в Backlog з Ready for Production, In Progress, Tech Check і Creative
Check. Дві перевірки додані тому, що помилку в тегах помічають саме там, а до цієї зміни задача з
перевірки могла піти лише вперед у Done або назад у In Progress — і жоден із цих шляхів тегів не
відкриває.
Done і Archived лишаються фінальними. Це не технічне обмеження: те саме скидання спрацювало б.
Це властивість завершеного пака — mark_pack_launched віддає його в кабінет, звіт про запуск
приходить проти опублікованих назв, і проти них же написані рядки аналітики. Пак потрапляє у фід
саме тому, що він у Done/Archived (Task.offered_to_integrations), тож «запускається» і «не
відкочується» — один факт. Через це правило живе у графі переходів, а не окремою перевіркою на
launched_at: окрема перевірка була б недосяжним кодом, який стверджує, що ловить випадок, якого
граф не допускає.
TasksService._discard_creatives при цьому лишається, але вже не як частина відкату Done. Він
тримає інваріант «задача в Backlog не має карток», і сьогодні має один реальний привід спрацювати:
задачі, чиї картки вставив ще старий прогон Ready for Production. Без нього кожен їхній повторний
запуск упирався б у _refuse_materialized_cards.
2.8 Не кожен вхід у Ready for Production запускає прогон
ALLOWED_TRANSITIONS пускає з In Progress назад у Ready for Production — це спосіб зняти задачу з
роботи, не скидаючи її. Але вхід у цей статус більше не означає «запустити автоматизацію»: рішення
читається з ребра, а не з цільового статусу (HANDLER_FREE_EDGES у workflow/transitions.py,
runs_handler(current, target)).
Чому саме так. Задача, яка робить цей крок, уже несе назву, план назв і Drive-папку від того
прогону, що привів її сюди, а виробництво вже назвало за цим планом свої файли. Повторний прогон
опублікував би той самий план (від Ready for Production і далі всі naming-поля закриті —
field_policy, тож нових вхідних даних у нього нема), перейменував би під нього ті самі файли й на
час прогону заморозив би задачу. Тому це ребро не ставить automation.state = pending, не пише
automation.started_at і не кладе job у чергу.
Наслідок для шару роутів: TasksService.transition_status повертає TransitionOutcome(task, handler_due) замість самої задачі. Обидва кінці переходу бачить лише сервіс — роут знає цільовий
статус, але не той, у якому задача насправді була, — тож відповідь їде разом із задачею, а не
перевираховується з payload.target_status.
Перезапуск автоматизації лишається за Backlog: це єдиний статус, де теги відкриваються.
2.5 Виконавець їде на картки на Done, а не на перевірках
on_tech_check / on_creative_check більше не чіпають креативи — до Done їх не існує.
_mint_creatives проставляє assignee_id із задачі в момент створення картки; LOC/WHT/ADPT/RSZ
виключені, бо їхня картка вже несе виконавця, скопійованого з оригінала.
За on_creative_check лишилась одна річ: для NEWDES/VARDES без виконавця роль Assignee бере на себе
Task Owner. Це запис на задачу, а не на картки, — саме його _mint_creatives потім і читає.
Побічний виграш: CreativesRepository.set_assignee_for_task — один update_many без подельтової
дельти на кожен креатив — не має більше жодного виклику й видалений. Разом із ним зникла й
однойменна прогалина в аудит-лозі.
Що при цьому не зникло — унікальність назв. Її давав унікальний індекс на creatives.name, який
спрацьовував на вставці; тепер вставка на цикл пізніше, тож перевірку відновлено на місці:
_reject_planned_collisions() відхиляє план, назви якого вже тримає наявна картка або план іншої
задачі поза Backlog (TasksRepository.planned_name_claims).
2.3 Поки прогон іде, задачу не можна редагувати
Хендлер генерує назву, картки креативів і Drive-папку зі знімка задачі, прочитаного на
початку прогону. Правка, що прилетіла після цього, у прогон не потрапить — і навпаки,
_inherit_from_origin() запише успадковані теги поверх щойно введених. Тому доки прогон іде,
задача заморожена:
| Запит | Поведінка |
|---|---|
PATCH /tasks/{id} |
409 TaskLockedError |
DELETE /tasks/{id} |
409 — каскад приховав би картки, які прогон ось-ось створить |
PATCH /tasks/{id}/status |
дозволено — це єдиний вихід із зависання |
«Прогон іде» — це status = ready_for_production і automation.state = pending
(Task.automation_running). Одного статусу мало: задача живе в Ready for Production і після
прогону, скільки завгодно довго, і замок на весь статус заморозив би її назавжди.
Цей замок — не те саме, що політика полів (field_policy.py, див.
Воркфлоу задач). Та закриває частину полів залежно від статусу і живе в
сервісі; цей — усі поля на час прогону, і живе всередині запису. Політика його навмисно не
дублює: вона не питає про automation_running, бо між читанням і записом воркер устигає зрушити.
Перевірка вбудована в сам запис (TasksRepository.update_unless_automation_running(...)), а не
зроблена окремим читанням перед ним: обидві половини стану змінює воркер, тож між читанням і
записом він устигає зрушити. Хендлер пише через звичайний update() і замка не бачить.
automation.state = pending ставиться і на вході в tech_check / creative_check / done —
у них теж є асинхронні хендлери. Але замикає тільки пара з ready_for_production: решта
хендлерів не генерує зі знімка, тож морозити під ними форму немає за що.
Фронтенд читає те саме правило з isAutomationLocked() і робить форму read-only, лишаючи
живим тільки контрол статусу — щоб користувач не друкував у поле, яке все одно не збережеться. Це
єдина половина замка, що лишилась на клієнті після переїзду політики полів на сервер, і причина
конкретна: строк життя pending — це показання годинника, а застрягла задача не породжує події, на
яку можна перемалюватись, тож зняти замок може тільки власний тік браузера.
Замок ставить одну вимогу й до сторінки задачі: переїзд статусу не повинен тягнути за собою
PATCH /tasks/{id}. Раніше тягнув — автозбереження порівнювало чернетку з задачею цілком,
разом із automation.*, generated_name і Drive-полями, які пише воркер. Кожен його запис
робив форму «зміненою», і наступний же кадр зі стріму вона відпрацьовувала записом застарілої
чернетки — у 409, поки прогон іде, і поверх щойно згенерованого, коли він закінчився. Тепер
питання «чи є що зберігати» ставиться лише по полях, які форма справді відправляє
(TASK_WRITABLE_KEYS), а серверну половину відповіді сторінка вливає в чернетку
(taskServerFields()) — так замок вмикається разом із переїздом, а не кадром пізніше.
Як замок знімається — і чому кожен запис мусить зрушити updated_at
Заморозку знімає лише результат прогону, і про нього сторінка дізнається одним каналом —
кадром зі стріму. Кадр підписаний updated_at, і сторінка відкидає той, що не новіший за версію,
яку вона вже малює: власний save повертається зі стріму так само, як чужа правка (§3.6).
Звідси інваріант, який легко порушити й важко побачити: будь-який запис у поля задачі мусить
зрушити updated_at. Хендлери воркфлоу цього не робили — _automation_set() писав
automation.state, generated_name і Drive-поля, а мітку лишав там, де її поставив перехід у
Ready for Production. Кадр «прогон завершився» приходив з міткою переходу, який його почав,
кожна відкрита сторінка читала його як власне відлуння й відкидала — і форма лишалась замороженою
на прогоні, що вже закінчився, до перезавантаження або до кінця 20-хвилинного вікна. Симптом
виглядав як баг політики полів («статус змінився, а поля не редагуються»), хоча жодне правило
політики до нього не дотичне.
Тому мітку ставить TasksRepository (_stamp()), а не кожен хендлер окремо: наступний запис не
може забути. Дві деталі, обидві навмисні:
_stamp()мутує переданий dict, а не копіює його.@auditedчитає цей dict після повернення із запису, тож мітка в копії не потрапила б у рядок аудиту — і replay перестав би відтворювати документ, який описує (tests/audit/test_replay_integration.pyце й ловить).- Хто ставить мітку сам — переїзд статусу, soft-delete — її зберігає, бо там вона означає момент
рішення, а не момент запису.
touch_last_comment()не пише жодного поля задачі й через_stamp()навмисно не йде (§3.6).
Стрім — єдиний канал, тож поки прогон у польоті (і тільки тоді, це секунди) сторінка задачі ще й
перечитує задачу сама, з інтервалом AUTOMATION_POLL_MS. Це не той poll, що прибрано в §3.1: там
дошка перечитувала найдорожчий ендпоінт по таймеру, тут — одна задача, заради єдиного стану, який
може лишити форму без виходу. Перечитати мало: форма судить про заморозку по своїй копії
серверної половини (draft.automationState), тож тік мусить ще й влити її через
taskServerFields() — інакше банер зникає і poll спиняється (вони дивляться на задачу), а поля
лишаються read-only на pending, у який вірить уже тільки чернетка.
2.4 Прогон не лишає по собі напівстворених карток
Вставка карток — єдина незворотна дія, тому вона йде останньою: спочатку валідація, потім
пошук джерел, потім Drive-папка (мережевий виклик, який падає найчастіше), і аж тоді
create_many. Якщо після вставки щось падає, _fail() жорстко видаляє саме ті картки, які
створив цей прогон, і вже потім повертає задачу в Backlog.
Нотифікації винесені за try. Раніше вони були всередині, і збій Slack відкочував уже
закомічений прогон у Backlog — картки лишались, а кожен наступний запуск бився об охоронця з
Creative cards already exist for this task і виходу з цього не було.
Охоронець на початку прогону тепер розрізняє два випадки:
| Стан | Рішення |
|---|---|
Картки є, generated_name порожній |
уламки прогону, що не дожив до запису імені (або ределівері Celery через acks_late) — видалити й продовжити |
Картки є, generated_name заповнений |
задача вже відпрацювала автоматизацію; відмова з текстом «видаліть картки перед повторним запуском» |
Видаляють їх через DELETE /creatives/{id} — дозволено, поки задача в backlog, і відмовлено
(409), якщо з картки вже зроблено іншу задачу. Статус задачі питає CreativesService через
Protocol TaskGate: kumquat-tasks залежить від kumquat-creatives, тож імпорт назад був би
циклом, який uv не пропустить. Видалення жорстке з тієї ж причини, що й у purge_for_task.
Drive-папку переставлено перед вставкою ще й тому, що create_task_folder() шукає-або-створює
за назвою: повторний прогон перевикористає ту саму папку, а не наробить дублів.
2.5 Збій enqueue відкочує перехід
Перехід комітиться в Mongo до постановки в чергу. Якщо брокер недоступний, задача
інакше зависла б у ready_for_production/pending назавжди — жоден воркер її вже не підбере,
а замок з 2.3 тримався б вічно.
TasksService.abort_transition() читає попередній статус з аудит-рядка, який щойно записав
цей самий перехід, і повертає задачу назад (теж CAS — паралельне ручне переміщення важливіше).
Аудит append-only, тому відкат — це окремий рядок, а не видалення. Роут віддає 503.
2.6 Чого досі немає
Якщо воркер убито по task_time_limit (600 с, worker/celery_app.py), хендлер не встигає
записати нічого — задача лишається в ready_for_production/pending без шляху назад. Автоматичного
watchdog немає. Дошка це показує (розд. 3.3), а ручний вихід один: перетягнути картку назад у
Backlog і запустити ще раз. Це працює й тоді, коли воркер помер уже після вставки карток —
розд. 2.4 прибере їх на наступному прогоні.
3. Правила для дошки
useTasksKanban (frontend/src/modules/tasks/hooks/use-tasks-kanban.ts) — свідомо рукописний
стор, а не SWR: вісім колонок з незалежною keyset-пагінацією не лягають на хук, який не
можна викликати в циклі. Через це всі гарантії, які SWR дав би безкоштовно, тут явні.
3.0 Три ендпоінти, кожен зі своєю роллю
| Ендпоінт | Коли | Що віддає |
|---|---|---|
GET /tasks/board/columns |
відкриття дошки, кожна подія change stream, після drop | перша сторінка всіх колонок + next_cursor і total кожної |
GET /tasks/board |
«Load more», глибокий refresh розгорнутої колонки | одна keyset-сторінка однієї колонки |
GET /tasks/schema |
один раз за сесію | filter_schema / sort_schema / status_schema |
Схеми не залежать ні від сторінки, ні від курсора, ні від користувача, тому їх немає в
meta фіда — інакше кожне оновлення дошки перечитувало б їх заново (useTaskSchemas
бере їх SWR-ом з revalidateIfStale: false).
3.0.1 Колонки виводяться з enum, а не перелічені руками
Дошка бере колонки з двох різних місць: рендерить options поля status
(record-table.tsx → groupField.options), а фетчить COLUMN_LABELS у use-tasks-kanban.ts.
Поки це були два рукописні масиви, вони могли розійтися — і колонка або малювалась порожньою, бо
її ніхто не читав, або читалась, але не малювалась.
Тепер обидва читають один STATUS_OPTIONS (task-mapping.ts), зібраний з Object.values(TaskStatus)
через STATUS_LABELS. Сама мапа лейблів закрита satisfies Record<TaskStatus, string>: доданий в
enum статус без лейбла і лейбл без статусу — обидва помилка компіляції, а не мовчазна дірка. Саме
на цьому впіймався забутий automation під час його прибирання.
optionIcons типами не закривається (ключі — лейбли, тобто просто рядки), тож цю половину тримає
task-fields.test.ts: іконка на кожну опцію плюс Deleted, і жодної іконки на статус, якого вже
немає.
3.1 Дошку штовхає change stream, а не poll
Дошка не опитує сервер. Кожна репліка API дивиться change stream по task__tasks
(kumquat_api/live.py) і роздає події в ті SSE-стріми, які обслуговує сама —
GET /api/v1/tasks/board/events, подія task_changed. Клієнт — use-board-events.ts
на EventSource.
Change stream сам є broadcast-шиною: кожна репліка бачить кожен запис, тому між Mongo і клієнтами немає Redis, і жодне місце запису не мусить пам’ятати про publish. Через це хендлери воркера, CLI та ручна правка в Mongo доїжджають до дошки так само, як рух через API.
Кадр — це інвалідація, а не картка: { task_id, status, updated_at }, де status —
лише підказка для дебагу, а updated_at — єдине, чим підписник відрізняє зміну, яку він уже
прочитав (власний запис), від чужої. Обидва поля можуть бути null: проєкція бачить їх на
insert, і на update — тільки якщо саме цей запис їх торкнувся. У кожної дошки свій фільтр і
пошук, тому «що ця зміна означає для цього клієнта» відповідає власний refetch клієнта.
Стрім через це не стає другим контрактом серіалізації поруч зі схемою картки.
| Правило | Чому |
|---|---|
| Дебаунс 500 мс перед refetch | один рух — це серія записів (перехід, далі те, що пише автоматизація) |
| Черга на клієнта — глибиною 1, переповнення відкидається | кадр у черзі вже означає «перечитай», другий не додає нічого; і watcher ніколи не чекає на повільного клієнта |
| Схована таба не перечитує, а позначає пропуск | GET /tasks/board/columns — найдорожче читання в API, а фонові таби це типовий випадок |
| Реконект робить один refetch | те, що зрушило поки нас не було, видно лише через читання; окремий replay не потрібен |
Реконект наш, а не вбудований в EventSource |
той повторив би той самий URL, а токен у ньому вже прострочений |
Коментар-рядок : ping кожні 25 с |
ingress-nginx має типовий proxy-read-timeout 60 с і зрізав би простій; коментар при цьому не доходить до жодного обробника подій |
X-Accel-Buffering: no на відповіді |
nginx за замовчуванням буферизує проксі-відповіді і тримав би кадри до заповнення буфера |
resume_after при перевідкритті; на ChangeStreamHistoryLost — свіжий стрім |
дірка в історії нічого не варта (клієнт усе одно перечитує), а стрім, що вічно ретраїть мертвий токен, коштує всього |
pymongo.timeout(0) навколо стріму, і не None |
клієнт має timeoutMS, а pymongo читає незаданий таймаут як «візьми клієнтський» — tailing-курсор через це рвався кожні 30 с |
PyMongoError → ретрай, будь-що інше → один logger.exception і стоп |
помилка коду не лікується ретраєм, вона лише спінить лог |
automation.state === "pending" більше не запускає опитування — результат автоматизації
приходить тим самим каналом. Стеля 5 хв лишилась локальним таймером: убитий hard time
limit воркер не пише результат (див. 2.6), тож для такої картки подія не прийде ніколи, і її
треба позначити як stale.
3.2 Один запит на оновлення, не набір колонок
Оновлення — це один GET /tasks/board/columns. Виводити, «куди саме може поїхати pending-картка»,
більше не треба: відповідь і так містить усі колонки, тож хендлер може перенести задачу
куди завгодно — дошка це побачить.
Виняток — колонка, яку користувач розгорнув через «Load more» (pages > 1). Її перша
сторінка з board-відповіді затерла б решту, тому такі колонки перечитуються окремим
GET /tasks/board з limit = pages × 10 (одним запитом, не walk по курсору) і виграють
у гонці за рахунок токена покоління.
3.3 Після drop
refreshAfterMove() — це той самий один запит по всіх колонках. Окремий обхід «якщо
картки немає ні в from, ні в to» більше не потрібен: картку видно там, де вона
опинилась, навіть якщо хендлер устиг спрацювати до відповіді на PATCH.
3.4 Гонки drag&drop
| Проблема | Рішення |
|---|---|
| Повільна стара відповідь перезаписує свіжу | токен покоління на колонку |
| Refresh посеред жесту висмикує картку з-під курсора | reconcile заморожений, поки activeId !== null |
| Refresh між drop і відповіддю PATCH смикає картку туди-сюди | картка «пришпилена» (pinned у reconcile), поки перехід у польоті |
| Повторний драг тієї ж картки → 409 | movingIds, useSortable({ disabled }) |
| Відкат при помилці затирає паралельні оновлення | відкочується одна картка (placeIn), а не snapshot дошки |
| 409 = серверний стан розійшовся | не просто локальний відкат, а перечитування |
Чисті функції розкладки живуть у core/data-table/kanban-containers.ts
(reconcile / placeIn / moveAcross) — саме щоб їх можна було покрити тестами.
Reconcile перезапускається за ключем
sig + pinned, не лише заsig. Зняття піна — це момент, коли серверна розкладка нарешті має право виграти; якби тригером був тількиsig, картка лишалась би там, куди її кинули оптимістично.
3.5 Чого дошка не робить
Перевпорядкування карток усередині колонки прибрано навмисно: у Task немає поля порядку,
тож воно не зберігалось і мовчки скидалось на наступному читанні.
3.6 Сторінка задачі сидить на тій самій шині
GET /api/v1/tasks/{task_id}/events — той самий стрім і та сама подія task_changed, лише
підписка зареєстрована з task_id, тож фільтрує сервер (BoardSubscribers.subscribe(task_id)),
а не клієнт. Дошка — це просто підписка без id. Клієнт — use-task-events.ts поверх спільного
use-event-stream.ts, який тримає токен, дебаунс, схований таб і реконект для обох стрімів.
Що робити з кадром, вирішує чиста функція taskChangeAction()
(modules/tasks/model/task-live-updates.ts), бо форма може мати незбережені правки:
| Кадр | Форма чиста | У формі незбережені правки |
|---|---|---|
updated_at новіший за версію, з якої зібрано форму |
refresh (той самий, що й кнопка Refresh, разом з фідами активності) + тост | банер «This task changed somewhere else» з кнопкою → та сама конфірмація, що й у Refresh |
updated_at не новіший (типово — власний save, який повернувся зі стріму) |
нічого | нічого |
Кадр без updated_at (delete, або catch-up після реконекту) |
refresh, без тоста | нічого — це підказка «щось могло зрушити», а не доказ чужої правки, тож банер не бреше |
Тост обов’язковий саме в лівій колонці: підміна значень, якої ніхто не оголосив, для читача
не відрізняється від «мені здалося». Текст дає друга чиста функція, liveUpdateMessage():
коли кадр несе status і він інший — «Moved to Ready for Production», інакше «Task updated».
Тон info (доданий у core/ui/toast) — приглушений, бо це не результат дії користувача.
Обидві половини разом ніколи не спрацьовують: або сторінка застосувала зміну і сказала про це,
або не застосувала і пропонує — але не «застосувала і вимагає дії».
Базова версія (syncedAt) оновлюється у двох місцях: коли сторінка приймає задачу у форму
і після власного save. Без другого сторінка вважала б власний запис чужим.
4. Тести
| Де | Що покрито |
|---|---|
backend/tests/api/test_task_workflow.py::TestAutomationConcurrentMove |
ручне переміщення під час роботи хендлера — успіх, revert, non-performance |
backend/tests/api/test_task_workflow.py::TestDoneNeedsTheFilesItPlanned |
409 на Done без файлів: задача не рухається, історії немає, VID не закривається фото, чужа папка й tombstone не рахуються |
backend/tests/tasks/test_deliverables.py |
саме правило: фолбек у Drive, недоступний Drive, задачі, яким нема з чим звірятися |
backend/tests/api/test_production_folder.py |
два читання папки — gdrive__files і живий Drive |
backend/tests/gdrive_sync/test_scanner.py |
роутинг обкладинок: фото в папці VID-задачі, не-VID задача, відео, правило по назві поза папкою задачі; фото, завантажене раніше за відео, і фото, назване довільно |
backend/tests/gdrive_sync/test_maintenance.py |
reroute_task_posters — dry-run, ідемпотентність, межі вибірки, зачистка creatives.media |
backend/tests/api/test_task_workflow.py::TestStatusEndpoint::test_enqueue_failure_rolls_the_move_back |
503 + відкат + компенсаційний рядок аудиту |
backend/tests/api/test_tasks_board_columns.py |
/tasks/board/columns і /tasks/schema: повний набір колонок, total проти розміру сторінки, продовження курсора на /tasks/board, спільний included |
frontend/src/core/data-table/kanban-containers.test.ts |
reconcile / placeIn / moveAcross, включно з пінами |
frontend/src/modules/tasks/model/task-workflow.test.ts |
canTransition, blockedStatuses, isAutomationLocked (замок тримає лише pending) |
frontend/src/modules/tasks/model/task-fields.test.ts |
options поля status дорівнюють STATUS_OPTIONS; іконка на кожну колонку і жодної зайвої (розд. 3.0.1) |
backend/tests/api/test_board_events.py |
updated_at у кадрі, фільтрація підписки по task_id, токен і розбір id на /tasks/{id}/events |
frontend/src/modules/tasks/model/task-live-updates.test.ts |
taskChangeAction() — refresh / notify / ignore; liveUpdateMessage() — текст тоста |
frontend/src/modules/tasks/components/task-edit-page.test.tsx |
що сторінка задачі робить з кадром: refresh + тост, банер, ігнор власного save, мовчання на ручний Refresh |
Хелпер _insert_automating() у бекенд-тестах не косметичний: він кладе задачу в
ready_for_production з automation.state = pending — рівно той стан, у якому API лишає задачу,
коли ставить хендлер у чергу, і проти якого CAS відкату порівнює. Тест, який кладе задачу в
backlog і кличе run_automation напряму, описує стан, якого в проді не буває.
Фронтенд-раннер — vitest: *.test.ts — чисті функції в node, *.test.tsx — компоненти в
jsdom (npm test). Решта фронтенду й далі перевіряється через tsc --noEmit + eslint.
5. Ручна перевірка
Потрібні api + worker + redis + mongo (docker compose up).
- Performance-задача з усіма полями → в
Backlog. - Drop у Ready for Production: картка одразу там з «Automation running…», решта дошки не перемальовується.
- Без F5 за ≤3 с напис зникає, приходить тост «Automation finished», задача лишається в Ready for Production — і форма знову редагована.
- Задача з
count = 0: сама повертається в Backlog з текстом помилки на картці. - Гонка (2.1):
docker compose stop worker→ drop у Ready for Production → вручну далі в In Progress →start worker. Задача має лишитись у In Progress. - Подвійний драг (3.4): перетягнути й одразу спробувати ще раз — друга спроба заблокована, 409 немає.
- Нелегальні колонки приглушені, drop не спрацьовує.
- Сторінка задачі (3.6): відкрити задачу у двох вкладках. Save у першій → друга без F5 показує нові значення і тост («Task updated», а на зміну статусу — «Moved to …»); якщо в другій є незбережена правка — замість підміни з’являється банер.
6. Індекси
Колонка — це {status: X} з сортуванням за created_at desc і добором за _id. Одинарних
індексів для цього мало: Mongo або бере status і сортує колонку в пам’яті, або йде по
created_at і сканує, доки не набере потрібний статус. Тому в kumquat_tasks/indexes.py
є композитний (status, created_at, _id) — фільтр, сортування і tiebreaker в одному індексі.
Заміряно на 50 000 задач, одна сторінка колонки (explain, executionStats):
| docs examined | план | |
|---|---|---|
| тільки одинарні | 5654 | SORT → FETCH → IXSCAN(status_1) |
| з композитним | 11 | LIMIT → FETCH → IXSCAN(status_1_created_at_1__id_1) |
Індекс висхідний по всіх трьох полях: дошка читає його у зворотному напрямку для свого
created_at desc, і той самий індекс обслуговує обидва напрямки сортування.
Лічильники колонок навмисно не агрегація. $group по status відповідає однією
командою, але не може використати індекс для групування і читає кожен документ (COLLSCAN).
Вісім count_documents по рівності відповідають з індексу status і виконуються
паралельно в межах одного запиту. Заміряно: 300 000 задач — 11 мс проти 104 мс на користь
count-ів, і розрив росте з розміром колекції.
7. Відомий борг
filter_schema у відповіді /tasks/schema рахується щоразу: два повні читання колекції
products (filter_options() і vocabularies() роблять по своєму list()) плюс два
distinct по задачах. Тепер це один раз за сесію замість дев’яти разів на кожен рефреш,
тому не критично, але сам ендпоінт — очевидний кандидат на кешування відповіді.