baton/docs/adr/ADR-007-offline-queue-v2.md

124 lines
8.3 KiB
Markdown
Raw Permalink Normal View History

# ADR-007: Паттерн офлайн-очереди
2026-03-20 20:44:00 +02:00
**Дата:** 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).
---
## Последствия
**При реализации учесть:**
2026-03-20 20:52:45 +02:00
1. **iOS Safari приватный режим:** `localStorage` недоступен → переход на IndexedDB не помогает (IndexedDB тоже может быть ограничен). Нужен graceful degradation: попытка записи в IndexedDB → при ошибке сигнал отправляется только online или теряется с явным UI-предупреждением. UI-текст для inline banner: «Сигнал не будет сохранён — вы в приватном режиме. Нажимайте кнопку только при активном интернете.»
2026-03-20 20:44:00 +02:00
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 — проверка перед регистрацией:**
```javascript
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. **ACTION: Обновить решение #1001** — изменить охват BackgroundSync с 85% до 78.75%, пометить ручной fallback как обязательный (не опциональный) элемент архитектуры.