baton/docs/adr/ADR-002-offline-pattern.md
2026-03-20 20:52:45 +02:00

8.3 KiB
Raw Blame History

ADR-002: Паттерн офлайн-очереди

Дата: 2026-03-20 Статус: Accepted Автор: Architect Agent (Kin pipeline, BATON-001)


Контекст

Baton — приложение экстренного сигнала. Критичное требование: сигнал не должен быть потерян, если пользователь нажал кнопку в момент отсутствия сети (тоннель, слабый сигнал, офлайн).

Сигнал должен быть сохранён локально и доставлен на сервер, как только соединение восстановится.

Аудитория: 300-400 пользователей, разные браузеры и платформы (Android Chrome, iOS Safari, Desktop Firefox/Chrome).


Варианты

Вариант A: IndexedDB outbox + BackgroundSync + online event fallback

Схема:

  1. Кнопка нажата → немедленная попытка fetch('/signal')
  2. Ошибка или offline → запись в IndexedDB outbox
  3. Trigger 1: window.addEventListener('online', flushOutbox) — main thread, все браузеры
  4. Trigger 2: SW регистрирует registration.sync.register('flush-outbox') — Chromium только
  5. SW обрабатывает sync event → читает IndexedDB → flush

Плюсы:

  • IndexedDB персистентна: не очищается при закрытии вкладки (в отличие от memory)
  • IndexedDB доступна как из main thread, так и из Service Worker → общее хранилище
  • BackgroundSync: браузер сам управляет повтором (Chrome может сработать даже при закрытой вкладке)
  • Dual trigger страхует: если BackgroundSync не сработал → online event
  • Квота IndexedDB: обычно GB (не 5 MB как localStorage)
  • Соответствует принятому решению #1006

Минусы:

  • IndexedDB API громоздкий → нужна обёртка (idb библиотека, ~1.9 KB gzip) или написать самому
  • BackgroundSync поддерживается только 78.75% браузеров (caniuse, март 2026) — Safari и Firefox не поддерживают
  • Сложнее отлаживать в DevTools, чем localStorage

Вариант B: localStorage queue + online event listener

Схема:

  1. Кнопка нажата → попытка отправки
  2. Ошибка → JSON.stringify очереди в localStorage
  3. window.addEventListener('online', flush) → отправить всё из очереди

Плюсы:

  • Самый простой вариант (~20 строк)
  • Нет зависимостей
  • Синхронный API — легко читать и писать

Минусы:

  • iOS Safari приватный режим: localStorage.setItem() бросает SecurityError → нужен try/catch → если упал, сигнал теряется
  • localStorage недоступна в Service Worker контексте → нельзя flush из SW
  • Лимит: 5 MB (достаточно, но IndexedDB надёжнее)
  • Нет BackgroundSync — только один trigger (online event)

Вариант C: Cache API + Request replay в Service Worker

Схема:

  • SW перехватывает failed POST запросы → сохраняет в Cache API
  • При появлении сети → повторяет запросы из кэша

Плюсы:

  • Нативная интеграция с SW fetch event
  • Нет отдельного хранилища

Минусы:

  • Cache API спроектирован для Response (кэш ответов), не для Request replay
  • Нет гарантий персистентности очереди (Cache может быть очищен браузером без предупреждения)
  • Workbox Background Sync внутри использует IndexedDB, не Cache API (косвенное свидетельство)
  • Нестандартное использование API → неожиданное поведение в edge cases

Решение

Выбран Вариант A: IndexedDB outbox + BackgroundSync + online event fallback

Соответствует зафиксированному решению #1006.


Обоснование

  1. IndexedDB — единственный вариант, доступный и в main thread, и в Service Worker. Это критично: flush может произойти как из app.js (online event), так и из sw.js (BackgroundSync event). Общее хранилище исключает дублирование кода.

  2. Dual trigger — страховочная сеть. BackgroundSync не обязателен для работы (Safari/Firefox — 21% юзеров обойдутся без него), но является бонусом для Chrome пользователей: flush случится даже при закрытой вкладке.

  3. Вариант B отклонён из-за проблемы iOS Safari приватного режима (решение #1003: не понижать явные требования) и недоступности в SW контексте. При том что приложение экстренного сигнала должно работать без потерь на iOS.

  4. Вариант C отклонён как злоупотребление API не по назначению. Cache API не гарантирует персистентность POST запросов.

  5. Размер зависимости idb: ~1.9 KB gzip — приемлемо. Альтернатива: написать минимальную обёртку (~30 строк) для трёх операций (add, getAll, delete).


Последствия

При реализации учесть:

  1. iOS Safari приватный режим: localStorage недоступен → переход на IndexedDB не помогает (IndexedDB тоже может быть ограничен). Нужен graceful degradation: попытка записи в IndexedDB → при ошибке сигнал отправляется только online или теряется с явным UI-предупреждением. UI-текст для inline banner: «Сигнал не будет сохранён — вы в приватном режиме. Нажимайте кнопку только при активном интернете.»

  2. Idempotency ключ: id: "${Date.now()}-${Math.random().toString(36).slice(2)}" — уникальный ключ каждой записи в outbox. Защита от дубликатов при повторных попытках. Бэкенд должен игнорировать дубликаты (INSERT OR IGNORE по client_id).

  3. Лимит попыток: attempts в outbox entry. После 3-5 неудачных попыток — показать пользователю UI-предупреждение. Не flush бесконечно.

  4. SW lifecycle: при обновлении SW (новая версия) старый SW активен до закрытия всех вкладок. Flush в процессе обновления → запрос может быть потерян. Idempotency ключ и INSERT OR IGNORE на бэкенде защищают от дубликатов.

  5. Background Sync — проверка перед регистрацией:

    if ('serviceWorker' in navigator && 'SyncManager' in window) {
      const reg = await navigator.serviceWorker.ready;
      await reg.sync.register('flush-outbox');
    } else {
      if (navigator.onLine) flushOutbox(); // немедленный fallback
    }
    
  6. Решение #1001 требует обновления: фактический охват Background Sync — 78.75% (21% без поддержки), не 85% как было зафиксировано. Ручной fallback — не «опциональный», а обязательный элемент архитектуры.