- 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>
123 lines
8.3 KiB
Markdown
123 lines
8.3 KiB
Markdown
# 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
|
||
|
||
**Схема:**
|
||
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 — проверка перед регистрацией:**
|
||
```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. **Решение #1001 требует обновления:** фактический охват Background Sync — 78.75% (21% без поддержки), не 85% как было зафиксировано. Ручной fallback — не «опциональный», а обязательный элемент архитектуры.
|