- git mv docs/adr/ADR-002-offline-pattern.md docs/adr/ADR-007-offline-queue-v2.md - Update title inside file: ADR-002 → ADR-007 - Update reference in docs/tech_report.md:410 - grep -r 'ADR-002-offline-pattern' returns no matches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
8.3 KiB
ADR-007: Паттерн офлайн-очереди
Дата: 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
Схема:
- Кнопка нажата → немедленная попытка
fetch('/signal') - Ошибка или offline → запись в IndexedDB outbox
- Trigger 1:
window.addEventListener('online', flushOutbox)— main thread, все браузеры - Trigger 2: SW регистрирует
registration.sync.register('flush-outbox')— Chromium только - SW обрабатывает
syncevent → читает 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
Схема:
- Кнопка нажата → попытка отправки
- Ошибка →
JSON.stringifyочереди вlocalStorage 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.
Обоснование
-
IndexedDB — единственный вариант, доступный и в main thread, и в Service Worker. Это критично: flush может произойти как из app.js (online event), так и из sw.js (BackgroundSync event). Общее хранилище исключает дублирование кода.
-
Dual trigger — страховочная сеть. BackgroundSync не обязателен для работы (Safari/Firefox — 21% юзеров обойдутся без него), но является бонусом для Chrome пользователей: flush случится даже при закрытой вкладке.
-
Вариант B отклонён из-за проблемы iOS Safari приватного режима (решение #1003: не понижать явные требования) и недоступности в SW контексте. При том что приложение экстренного сигнала должно работать без потерь на iOS.
-
Вариант C отклонён как злоупотребление API не по назначению. Cache API не гарантирует персистентность POST запросов.
-
Размер зависимости
idb: ~1.9 KB gzip — приемлемо. Альтернатива: написать минимальную обёртку (~30 строк) для трёх операций (add, getAll, delete).
Последствия
При реализации учесть:
-
iOS Safari приватный режим:
localStorageнедоступен → переход на IndexedDB не помогает (IndexedDB тоже может быть ограничен). Нужен graceful degradation: попытка записи в IndexedDB → при ошибке сигнал отправляется только online или теряется с явным UI-предупреждением. UI-текст для inline banner: «Сигнал не будет сохранён — вы в приватном режиме. Нажимайте кнопку только при активном интернете.» -
Idempotency ключ:
id: "${Date.now()}-${Math.random().toString(36).slice(2)}"— уникальный ключ каждой записи в outbox. Защита от дубликатов при повторных попытках. Бэкенд должен игнорировать дубликаты (INSERT OR IGNORE поclient_id). -
Лимит попыток:
attemptsв outbox entry. После 3-5 неудачных попыток — показать пользователю UI-предупреждение. Не flush бесконечно. -
SW lifecycle: при обновлении SW (новая версия) старый SW активен до закрытия всех вкладок. Flush в процессе обновления → запрос может быть потерян. Idempotency ключ и
INSERT OR IGNOREна бэкенде защищают от дубликатов. -
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 } -
Решение #1001 требует обновления: фактический охват Background Sync — 78.75% (21% без поддержки), не 85% как было зафиксировано. Ручной fallback — не «опциональный», а обязательный элемент архитектуры.