Інбокс: архітектура
Рядки сповіщень, 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
- Значення в
NotificationKind; якщо є Slack-транспорт — уSLACK_KINDS. - Функція ключа в
dedupe.py— відповісти на «що вважається тією ж подією». - Білдер рядка поруч з доменом (
kumquat_tasks/inbox.pyабоworker/notifications.py), не вkumquat_notifications. - Запис через
Inbox.record; якщо є Slack — перед DM спитатиallows(..., SLACK). 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.