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

Інбокс: архітектура

Рядки сповіщень, dedupe, одна точка запису з преференсами, доставка через change stream і єдиний SSE на вкладку, sweep дедлайнів.

Внутрішній центр сповіщень: дзвіночок у хедері з лічильником, попап з останніми рядками, бічна панель з повним фідом і налаштуваннями. Рядки приходять наживо, без поллінгу. Slack лишився як був — інбокс працює поруч, і за замовчуванням обидва канали увімкнені для кожного типу події. Що бачить користувач — у Інбокс: сповіщення в Kumquat.

Де що лежить:

  • packages/kumquat-notifications/ — модель, репозиторій, Inbox, dedupe-ключі, індекси. Пакет доменно-нейтральний: не знає про задачі, ранни чи експорти.
  • packages/kumquat-tasks/src/kumquat_tasks/inbox.py — рядки для подій workflow задач.
  • kumquat_api/worker/notifications.py — рядки для коментарів, раннів канвасу й експортів.
  • kumquat_api/events.py, kumquat_api/inbox_live.py, kumquat_api/live.py — живий стрім.
  • kumquat_api/cli/notify_deadlines.py + dags/dag_kumquat_task_deadlines.py — дедлайни.
  • frontend/src/modules/inbox/ і frontend/src/core/api/live-events.tsx — клієнт.

Назовні: GET /api/v1/inbox, POST /api/v1/inbox/read, POST /api/v1/inbox/read-all, GET|PUT /api/v1/inbox/preferences, GET /api/v1/events (SSE).

Чому саме так

  • Рядок = одна подія для одного отримувача, відрендерена в момент запису. title, body, актор і entity_label пишуться одразу, тож відкрити інбокс — один індексований запит, а не fan-in по задачах, коментарях, раннах і юзерах. Ціна та сама, що в аудит-лозі: перейменована задача в старих рядках лишається під старою назвою. Read-стан — теж per-person, через це рядок на отримувача, а не на подію.
  • Одна точка запису — Inbox. Вона й пише рядок з урахуванням inbox-преференса, і відповідає Slack-транспорту на allows(..., SLACK). «Хто хоче про це чути» вирішується раз на подію, а не раз на транспорт. Inbox.record ніколи не кидає: коментар уже збережено, ранн уже завершився, і виняток з інбоксу в Celery-джобі означав би redelivery джоби, яка вже закомітилась.
  • Преференс, який не вдалось прочитати, = дефолти («все увімкнено»). Втратити подію через недоступну другу колекцію гірше, ніж надіслати зайве.
  • Преференси — окрема колекція notification__preferences, не поле юзера: UsersRepository.upsert цілком замінює дзеркало юзера на кожен Clerk-вебхук user.updated і стер би все, чого немає в моделі User.
  • Доставка — Mongo change stream, а не publish із місць запису. Інсерт і є подією: рядок, написаний API, будь-якою Celery-чергою, CLI під Airflow чи руками в Mongo, доходить до відкритого інбоксу однаково, і жодне місце запису не мусить пам’ятати про доставку.

Модель і колекції

notifications (Notification, models.py):

Поле Що це
recipient_id Clerk id. Єдиний контроль доступу до колекції — завжди з токена, ніколи з запиту
kind NotificationKind — 15 значень, див. нижче
dedupe_key унікальний, див. «Ідемпотентність»
title, body готовий текст
url in-app шлях (починається з /) або абсолютний URL — лише для Google Sheet експорту
actor_* відсутні для подій без виконавця (ранн завершився, sweep дедлайнів)
entity_type, entity_id, entity_label task / comment / run / export
read_at null = непрочитане

Індекси (kumquat_notifications/indexes.py, створюються на старті):

  • (recipient_id, created_at desc, _id desc) — під keyset_page, який сортує по обох полях; одно-полевий індекс перетворив би сторінку на blocking top-K.
  • unread — partial (recipient_id, read_at) з read_at: null, для бейджа.
  • dedupe — unique на dedupe_key.
  • TTL на created_at, 30 днів (NOTIFICATION_RETENTION_DAYS) — окреме вікно, не спільний RETENTION_DAYS, бо сповіщення не є видаленими даними. Ставиться окремо, щоб зміна вікна на існуючій базі йшла через collMod.

notification__preferences (NotificationPreferences): _id = Clerk id, channels: {kind: [inbox, slack]}. Відсутній kind = DEFAULT_CHANNELS (обидва). Індексів немає — читається лише по _id, і TTL сюди доходити не повинен. PUT /inbox/preferences приймає всю сітку, а не дельту: пропуск не може одночасно означати «повернути дефолт» і «не чіпати». Клієнт викидає з мапи kinds, що повернулись до дефолту.

Лічильник непрочитаних рахує до 100 (UNREAD_CEILING + 1) і показується як «99+»; віддається разом зі сторінкою фіду, а не окремим ендпоінтом.

Ідемпотентність: dedupe_key

Celery працює з task_acks_late, тож перевидана джоба проходить увесь шлях сповіщень ще раз. Unique-індекс на dedupe_key робить другий прохід no-op: NotificationsRepository.emit вставляє по одному й мовчки пропускає DuplicateKeyError. Усі ключі — в одному місці, kumquat_notifications/dedupe.py (плюс _event_key у kumquat_tasks/inbox.py для подій workflow), щоб джоба та її redelivery не могли розійтись.

Що означає «та сама подія» — різне для різних kinds:

  • Коментар — comment_id + kind + recipient.
  • Призначення, зміна статусу, успіх автоматизації — включають task.updated_at: redelivery = та сама подія, повторне призначення через місяць — нова.
  • Решта подій workflow (збій автоматизації тощо) — automation.started_at + sha256 тексту. started_at ставить перехід, що поставив прогін у чергу, тож redelivery — та сама подія, а повторний прогін з тією самою помилкою — нова; текст розрізняє два результати одного прогону.
  • Дедлайн — task_id + kind, без часу: один рядок на задачу на бакет. Тому hourly sweep каже «due soon» раз, і тому ж перенесення дедлайну не ре-нотифікує — ключ на самому дедлайні зробив би кожне редагування дати новим пінгом.
  • Ранн — run_id + kind + спроба; для run_awaiting_input ще node_id + item_index, бо mapped-гейт питає окремо на кожен item. Спроба — started_at (або finished_at, якщо ранн впав ще до виконання): рестарт перезапускає ранн під тим самим id, а re-roll заново ставить питання гейту, тож без неї другий результат читався б як redelivery першого.
  • Експорт — export_id + kind.

Звідки беруться рядки

Kind Де пишеться Отримувач Slack
comment_mention _record_mention_notification (worker) згаданий так
comment_reply notify_comment_reply (worker, одна джоба на коментар) усі автори коментарів задачі мінус автор і згадані ні
task_assigned WorkflowNotifier.assigned_ready_for_production виконавець, лише якщо задача вже в RFP так
task_status_changed notify_task_status_changed (з transition-роуту) assignee + task owner мінус актор лише Creative Check
task_due_soon, task_overdue kumquat-cli notify-deadlines виконавець ні
run_* _announce_run у _execute run.owner_id ні
automation_*, drive_files_left_behind, legacy_remap_unresolved WorkflowNotifier._dm_or_fallback task owner так
export_* _notify_export / _notify_export_failed хто замовив так

Деталі, на які легко наступити:

  • SLACK_KINDS (enums.py) — kinds, за якими реально стоїть Slack-транспорт. Сервер віддає його в PreferencesView.slack_kinds, і сторінка налаштувань малює прочерк замість перемикача для решти — kind, що отримає Slack-шлях, почне показувати перемикач сам. tests/tasks/test_notifications.py падає, якщо виклик _dm_or_fallback передає kind, якого тут немає.
  • Вимкнений Slack — це return, а не fallthrough у _dm_or_fallback: інакше заглушена подія пішла б fallback’ом у CREATIVE-канал, який читає вся команда.
  • Події, адресовані каналу (tech_check, done_manual_launch), лишаються тільки в Slack — у каналу немає Clerk id, під яким завести рядок.
  • task_status_changed у Slack означає лише DM власнику на Creative Check: creative_check() питає allows(owner, TASK_STATUS_CHANGED, SLACK) і викликає _dm_or_fallback без kind, бо inbox-рядок про цей перехід уже пише transition-роут.
  • Статуси, про які оголошуємо — ANNOUNCED_STATUSES у kumquat_tasks/inbox.py (RFP, Tech Check, Creative Check, Done). Окремий список, а не transitions.ASYNC_HANDLER_STATUSES, хоч сьогодні вони збігаються: п’ятий статус з автоматизацією не повинен почати сам себе оголошувати. Джоба перечитує задачу й мовчить, якщо вона вже в іншому статусі.
  • Enqueue ніколи не валить запит. Коментар і перехід уже закомічені; 500 на enqueue змусив би клієнт повторити дію. Тому і create_comment, і transition-роут ловлять і логують.
  • _announce_run ніколи не кидає — він усередині try у _execute, чий хендлер позначив би успішний ранн як failed. Скасований ранн не нотифікується.

Живий стрім: одне SSE на вкладку

notifications ──change stream──► inbox_live.watch_notifications ─┐
                                                                  ├─► EventSubscribers ─► GET /api/v1/events
tasks ─────────change stream──► live.watch_board ────────────────┘      (SSE, по з'єднанню на вкладку)
  • По одному watcher на колекцію на репліку (uvicorn без --workers, под = процес), обидва стартують у lifespan і годують один реєстр EventSubscribers. inbox_live дивиться лише insert: read-стан наживо не їде — для цього на update-операціях знадобився б updateLookup лише щоб дістати recipient_id, і один «mark all read» став би сотнями читань.
  • Кадри notification несуть увесь рядок — поява рядка не коштує додаткового запиту. Кадри task_changed лишаються інвалідаціями і йдуть усім з’єднанням нефільтровано: з’єднання не знає, яка сторінка відкрита, тож обирає клієнт (useTaskFrames, useBoardEvents). Сповіщення маршрутизуються за recipient_id і ніколи не торкаються чужого з’єднання.
  • Дві черги на з’єднання, глибиною 50. Кадр задачі — нудж, що приходить пачками, коли дошка жвава; сповіщення — дані однієї людини. Спільна черга дозволила б churn дошки витіснити чиюсь згадку. Переповнена черга скидається цілком і замінюється одним catch-up кадром (resync для інбоксу, task_changed з null для задач), після чого нічого не буферизує, доки клієнт його не прочитає. Watcher ніколи не чекає на повільного клієнта.
  • Heartbeat : ping кожні 25 с — тиша означає тихий акаунт, а не мовчки обірваний сокет.
  • Токен — у query (?token=), бо EventSource не вміє ставити Authorization. Отримувач береться з токена.
  • Нечитабельний рядок пропускається, а не валить watcher — це рядок від новішого деплою посеред rollout. Невідомий баг зупиняє watcher до рестарту (лог live delivery is off until restart).

Окремих /tasks/board/events і /tasks/{id}/events більше немає — дошка й сторінка задачі читають той самий /events.

Дедлайни

Єдиний kind без події за ним: у момент настання дедлайну нічого не відбувається. Тож це sweep — DAG dag_kumquat_task_deadlines.py щогодини (0 * * * *, max_active_runs=1) запускає kumquat-cli notify-deadlines. Прецедент — dag_kumquat_archive_done_tasks.py; загальне про DAG-и — Airflow DAG-и.

  • Відкриті статуси (Backlog…Creative Check), є assignee_id, не видалена.
  • task_due_soon — дедлайн у (now, now + 24h].
  • task_overdue — дедлайн у (now − 7d, now]. Тиждень свідомо набагато менший за 30-денний TTL: задача виходить з вікна задовго до того, як її рядок експайриться, тож ніхто не отримає повтор після TTL. Він же не дає першому запуску позначити кожен давній дедлайн у колекції.
  • Hourly, а не daily, бо «due within a day» корисне лише поки воно правда. Ретраї й перекриття безпечні — dedupe-ключ.

Клієнт

  • LiveEventsProvider (core/api/live-events.tsx) — єдиний EventSource на вкладку, з огляду на per-origin бюджет з’єднань, у який має влізти ще й сокет ранну канвасу. Кадри роздаються слухачам синхронно, не через state — два кадри в одному тіку склеїлись би в один рендер. Фічі підписуються через useLiveEvent. Поки вкладка прихована, тримаються лише кадри notification, до 20 (keepWhileHidden, HELD_LIMIT у use-event-stream.ts); решта — і всі понад ліміт — згортаються в один reconnect, який віддається останнім, коли вкладка повертається. Інакше фонова вкладка днями копила б кожну зміну задач в org.
  • InboxLiveProvider тримає лічильник: +1 на кожен кадр без запиту, а будь-яке читання сторінки перезаписує його серверним значенням (syncUnreadCount). Початкове значення — list({ limit: 1 }) на старті; його ж повторює кожен resync/reconnect, бо після них інкременти вже нічого не варті.
  • useInbox — useSWRInfinite з keyset-курсором (рядки приходять у голову списку, поки його читають, — offset там повторює або пропускає). Нове сповіщення ревалідує першу сторінку, і лише поки список на екрані; попап нічого не фетчить, доки закритий.
  • Тости (InboxToaster) — лише TOASTED_KINDS: згадка, ранн чекає, і три failed. Тост пригнічується, якщо користувач уже дивиться на сутність (useIsLookingAt: префікс шляху, для ранну — ще ?run=). Пригнічується тільки тост, рядок лишається — тому серверу не треба знати, хто що дивиться. Слухає кадри напряму (useLiveEvent), не стан InboxLiveProvider (там лише лічильник seq): вкладка, що повернулась, віддає накопичене одним синхронним циклом, і через state React лишив би з нього тільки останній. Більше трьох за раз — один підсумковий тост замість кожного.
  • Налаштування — оптимістичні (mutate з optimisticData + rollback). Лейбли й іконки kinds — model/notification-kinds.ts; notification-kinds.test.ts падає, якщо новий kind забули.

Як додати новий kind

  1. Значення в NotificationKind; якщо є Slack-транспорт — у SLACK_KINDS.
  2. Функція ключа в dedupe.py — відповісти на «що вважається тією ж подією».
  3. Білдер рядка поруч з доменом (kumquat_tasks/inbox.py або worker/notifications.py), не в kumquat_notifications.
  4. Запис через Inbox.record; якщо є Slack — перед DM спитати allows(..., SLACK).
  5. make generate-api (OpenAPI → TS-клієнт), потім додати запис у NOTIFICATION_KINDS / KIND_SECTIONS, за потреби — у TOASTED_KINDS.

Відомі межі

  • Read-стан не синхронізується між вкладками наживо: інша вкладка побачить його на наступному читанні сторінки.
  • Сповіщення не оновлюються заднім числом — перейменування задачі не змінює старі рядки.
  • Перенесення дедлайну не ре-нотифікує (див. dedupe).
  • comment_reply отримують лише ті, хто вже коментував задачу; власник чи виконавець, що не писав у треді, відповіді не отримає (статусні переходи вони отримують окремо).
  • Про статус оголошується лише перехід через transition-роут; відкат задачі автоматизацією в Backlog — це automation_failed, а не task_status_changed.

Тести: tests/notifications/test_repository.py, tests/api/test_inbox.py, tests/api/test_events.py, tests/worker/test_inbox_emitters.py, tests/cli/test_notify_deadlines.py, tests/tasks/test_notifications.py.

Was this page helpful?