Skip to content
Kumquat
Esc
↑↓navigate↵open⌘Jpreview
On this page

Автоматизація задач у канбані

Контракт між 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). Два джерела, саме в такому порядку:

  1. gdrive__files по folder_id = task.drive_folder_id — одне індексоване читання, але сканер ходить раз на 5 хвилин, тож щойно завантажений файл там ще не видно.
  2. Сам 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).

  1. Performance-задача з усіма полями → в Backlog.
  2. Drop у Ready for Production: картка одразу там з «Automation running…», решта дошки не перемальовується.
  3. Без F5 за ≤3 с напис зникає, приходить тост «Automation finished», задача лишається в Ready for Production — і форма знову редагована.
  4. Задача з count = 0: сама повертається в Backlog з текстом помилки на картці.
  5. Гонка (2.1): docker compose stop worker → drop у Ready for Production → вручну далі в In Progress → start worker. Задача має лишитись у In Progress.
  6. Подвійний драг (3.4): перетягнути й одразу спробувати ще раз — друга спроба заблокована, 409 немає.
  7. Нелегальні колонки приглушені, drop не спрацьовує.
  8. Сторінка задачі (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 по задачах. Тепер це один раз за сесію замість дев’яти разів на кожен рефреш, тому не критично, але сам ендпоінт — очевидний кандидат на кешування відповіді.

Was this page helpful?