Изменение описывает три дефекта двусторонней синхронизации: повторную загрузку собственного превью, удаление серверной копии до подтверждения замены и повторную разгрузку только что восстановленного оригинала.
172 lines
17 KiB
Markdown
172 lines
17 KiB
Markdown
# Design
|
||
|
||
## Context
|
||
|
||
Мотивацию и инцидент см. в `proposal.md`; требования — в дельте `specs/phone-album-sync/`.
|
||
Здесь только текущее состояние, задающее подход.
|
||
|
||
- Движок синхронизации (`android/.../data/sync/*`) считает диффом «MediaStore сейчас ↔ индекс
|
||
Room». Индекс: `SyncedAlbumEntity` (связь и режим), `DeviceAssetEntity` (`assetKey`,
|
||
`contentHash`, `serverPhotoId`, `state`, `origin`), `deleted_assets` (журнал удалений),
|
||
`upload_jobs` (очередь загрузок с `deviceAlbumId`, `assetKey`, `contentHash`, `syncMode`).
|
||
- `SyncPlanner.plan` — чистая функция: сравнивает `LocalAsset.contentHash` с
|
||
`IndexedAsset.contentHash` и отдаёт `Upload`, `Link`, `DeleteOnServer`, `RenameOnServer`.
|
||
Роль файла она различает только по `state == PREVIEW`; при расхождении состояния с диском
|
||
смена роли читается как «содержимое изменилось».
|
||
- `OffloadManager` пишет в превью метку провенанса (`ProvenanceStamp`:
|
||
`album`, `photo`, `hash`, `device`, `mode`, `asset`, `origin`), где `hash` — контрольная
|
||
сумма оригинала на момент разгрузки.
|
||
- Загрузка асинхронная (`UploadRepository`/`UploadWorker` на WorkManager); в задание
|
||
записывается `contentHash`, посчитанный на момент планирования, а байты читаются по URI уже
|
||
во время загрузки. `UploadWorker.recordProvenance` в режиме переноса разгружает оригинал
|
||
сразу после успешной загрузки — вне мьютекса `SyncCoordinator`.
|
||
- `LocalAssetProvider.hashCached` переиспользует посчитанный sha256, пока не изменилась пара
|
||
`(size, dateModified)` **из MediaStore**, которая после записи файла через дескриптор
|
||
остаётся старой (на инциденте: `size=169637` в приложении против `2 184 212` на диске).
|
||
- Room: версия 8, миграции объявлены явно (`MIGRATION_6_7`, `MIGRATION_7_8`),
|
||
`fallbackToDestructiveMigration` включён.
|
||
- Сервер менять не нужно: `DELETE /api/photos/{id}` (мягко, в корзину),
|
||
`POST /api/photos/{id}/restore` и `original_checksum` в ответе загрузки уже есть.
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:**
|
||
|
||
- Инвариант «контрольная сумма = идентичность оригинала, состояние = роль локального файла»:
|
||
разгрузка и возврат не меняют содержимое и не запускают загрузку или удаление.
|
||
- Замена версии безопасна: прежняя серверная копия исчезает только после подтверждения новой.
|
||
- Расхождение состояния в индексе самоисправляется, а не превращается в разрушительное
|
||
действие.
|
||
- Разово вернуть два пострадавших оригинала и убрать деградировавшие копии.
|
||
|
||
**Non-Goals:**
|
||
|
||
- Серверная модель версий и идентичности содержимого; эндпоинт «заменить содержимое фото».
|
||
- Чистка кэша альбома на телефоне (пустые плитки удалённых фото — отдельная задача).
|
||
- Доверие к метаданным MediaStore при кэшировании хешей: остаётся как есть, риск закрывается
|
||
сверкой контрольной суммы (см. решение 4).
|
||
- Семантика зеркалирования чужих серверных фото и права на удаление.
|
||
|
||
## Decisions
|
||
|
||
### 1. Роль и содержимое различаются по метке превью, а не по состоянию индекса
|
||
|
||
Идентичность элемента — `IndexedAsset.contentHash` (контрольная сумма оригинала либо
|
||
последней подтверждённой новой версии). Роль локального файла определяется меткой провенанса:
|
||
если у файла есть метка, её `hash` совпадает с идентичностью элемента, а фактический хеш файла
|
||
от неё отличается — это созданное приложением превью, а не новая версия.
|
||
|
||
Альтернативы: доверять только `state` индекса — отклонено, потому что расхождение состояния и
|
||
есть причина инцидента (в `device_assets` элементы с превью на диске стояли как `LOCAL`);
|
||
сравнивать разрешение с серверной копией — отклонено, потому что законное уменьшение
|
||
(кроп/экспорт) тоже меньше по размеру, а серверная копия может быть уже деградировавшей.
|
||
|
||
Метка читается только тогда, когда хеш содержимого пересчитывался (промах кэша), то есть
|
||
примерно один раз на изменение файла.
|
||
|
||
Метка указывает на элемент, чьим превью является файл, — для фото это сам элемент, для
|
||
видео это исходное видео, уступившее место постеру под новым `MediaStore`-id. Поэтому
|
||
распознавание опирается на `asset` из метки, а не на совпадение ключей: постер
|
||
разгруженного видео связывается с видео, а «исчезнувший» с устройства элемент, который всё
|
||
ещё представлен постером, не считается удалённым и не удаляет серверную копию. Скачанные
|
||
(зеркальные) файлы метку-ключ не несут и по этому пути не обрабатываются.
|
||
|
||
### 2. Планировщик возвращает действие «выправить роль», а не молчит
|
||
|
||
Превью, принятое за оригинал, не просто игнорируется: планировщик отдаёт
|
||
`SyncAction.MarkPreview(assetKey)`, координатор ставит элементу состояние `PREVIEW`. Это
|
||
чинит и `GallerySyncPolicy` (иначе он в режиме переноса снова разгружает элемент как
|
||
`LOCAL`), и `reflectLostCopies` (переход `PREVIEW ↔ ORPHANED`). Альтернатива — чинить роль
|
||
только в координаторе до планирования — отклонена: тогда знание о метке пришлось бы
|
||
дублировать в двух местах.
|
||
|
||
### 3. Незавершённая загрузка блокирует разгрузку
|
||
|
||
`SyncCoordinator.offloadUploaded` пропускает элементы, для которых очередь загрузок знает
|
||
незавершённое задание (`SyncUploadQueue.isPending`). Так файл не может быть переписан
|
||
превью в момент, когда задание ещё читает байты по URI. Альтернатива — синхронный offload
|
||
внутри воркера после загрузки — оставлена, но только для уже подтверждённого фото этого
|
||
элемента, когда незавершённых заданий по нему нет.
|
||
|
||
Побочный эффект: разгрузка в режиме переноса завершается после того, как очередь опустеет,
|
||
то есть чуть позже, чем сегодня (требование «автоматическая разгрузка после появления копии
|
||
на сервере» сохраняется).
|
||
|
||
### 4. Замена версии: удаление прежней копии — только после подтверждения
|
||
|
||
При изменении содержимого планировщик отдаёт `SyncAction.ReplaceUpload(asset, stalePhotoId)`
|
||
вместо пары «`Upload` + `DeleteOnServer`»:
|
||
|
||
- координатор пишет в журнал удалений запись для `stalePhotoId` (надгробие блокирует
|
||
повторную материализацию прежней копии, пока идёт замена) и ставит задание загрузки с
|
||
новым полем `replacesPhotoId`;
|
||
- **`UploadWorker`** после загрузки сверяет `original_checksum` ответа с хешем задания; при
|
||
совпадении удаляет прежнюю копию (мягко, в корзину), записывает провенанс и разгружает
|
||
оригинал в режиме переноса;
|
||
- при ошибке или расхождении контрольной суммы прежняя копия остаётся, провенанс не
|
||
записывается, замена повторится в следующем проходе (надгробие сохраняется, задание
|
||
остаётся с ошибкой).
|
||
|
||
Сверка различает три исхода, и это принципиально: **совпало** — можно удалять прежнюю копию;
|
||
**разошлось** — загрузку нельзя принимать за новую версию (принятую копию отбрасываем мягко,
|
||
элемент не считается загруженным, прежняя копия остаётся); **не сообщено** — загрузка
|
||
остаётся состоявшейся, но прежняя копия не удаляется. Третий исход понадобился сразу: ответ
|
||
на загрузку не отдавал `original_checksum` (сервер писал контрольную сумму только в объект,
|
||
хотя требование способности `photos` «Контрольная сумма оригинала» распространяется на данные
|
||
фото вообще), и первая версия сверки, считавшая отсутствие контрольной суммы расхождением,
|
||
отклоняла все синхронные загрузки. Дефект сервера исправлен: `Service.Upload` заполняет
|
||
`p.OriginalChecksum` до ответа и публикации события; клиент при этом остаётся устойчивым к
|
||
инстансу, который контрольную сумму не сообщает.
|
||
|
||
Схема: `upload_jobs.replacesPhotoId TEXT` (Room 8 → 9). Задание — естественный носитель
|
||
одноразовой замены: альтернатива с колонкой в `device_assets` требует явной очистки после
|
||
подтверждения, а привязка надгробий к ассетам всё равно требует миграции. Немедленное
|
||
удаление как сегодня отклонено: неудачная загрузка оставляет альбом без живой копии.
|
||
Явное удаление элемента на устройстве (`SyncAction.DeleteOnServer`) сохраняет прежнее
|
||
поведение — файла уже нет, и намерение выражено пользователем.
|
||
|
||
### 5. Возврат оригинала оставляет состояние «оригинал + серверная копия»
|
||
|
||
`RestoreManager` после записи оригинала помечает элемент `UPLOADED`, а не `LOCAL`.
|
||
`LOCAL` остаётся значением для «оригинал есть, на сервер не загружался». Вместе с решением 2
|
||
это убирает повторную разгрузку вернувшихся оригиналов и петлю «вернул → выгрузил → залил».
|
||
|
||
### 6. Разовое восстановление двух пострадавших фото
|
||
|
||
Операция, а не постоянное поведение: для `IMG_20261004_163151.jpg` восстановить из корзины
|
||
оригинал `6d4e1897` в альбом `N2` и удалить деградировавшую копию `b0b29905`; для
|
||
`IMG_20261009_153136.jpg` — восстановить `991ea4ee` и удалить `1f49e8fb`. Нужны права
|
||
владельца инстанса; если креды недоступны, пользователю выдаются готовые команды. Синхронизация
|
||
альбома на телефоне к этому времени уже выключена, поэтому вмешательства в индекс не требуется.
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- **[У файла нет метки провенанса]** (превью записано до появления метки или переписано
|
||
сторонним редактором) → роль берётся из состояния индекса, как раньше; деградацию
|
||
ограничивает решение 4: прежняя копия не удаляется, пока новая не подтверждена, поэтому
|
||
потеря оригиналов невозможна, а лишняя копия заметна и обратима через корзину.
|
||
- **[Сервер не сообщает контрольную сумму]** → загрузка остаётся состоявшейся, но прежняя
|
||
копия не удаляется и замена повторяется; так инстанс, отставший от спеки, не может
|
||
привести к отклонению всех синхронных загрузок (этот исход уже случался на живой проверке).
|
||
- **[Хеш посчитан в момент перезаписи файла]** (гонка с собственной записью) → возможна
|
||
загрузка с хешем, не совпадающим с байтами; сверка контрольной суммы (решение 4) отменяет
|
||
такую замену и не удаляет прежнюю копию.
|
||
- **[Правка собственного превью пользователем]** → элемент считается превью и правка не
|
||
уедет на сервер. Осознанный компромисс: файл несёт метку разгруженного превью, а
|
||
требование «метка утрачена» уже говорит не удалять и не загружать неопознанное повторно.
|
||
- **[Разгрузка откладывается до опустошения очереди]** → режим переноса освобождает место
|
||
позже. Освобождение остаётся автоматическим, но перестаёт конкурировать с загрузкой.
|
||
- **[Миграция Room 8 → 9]** → одна `ALTER TABLE` с nullable-колонкой; откат безопасен
|
||
(колонка просто игнорируется), `fallbackToDestructiveMigration` остаётся страховкой.
|
||
- **[Ручная операция по данным инстанса]** → восстановление из корзины обратимо, удаление
|
||
выполняется мягко; до и после сверяются размеры и контрольные суммы.
|
||
|
||
## Migration Plan
|
||
|
||
1. Миграция `MIGRATION_8_9` добавляет `upload_jobs.replacesPhotoId`; старые задания читаются
|
||
как «без замены».
|
||
2. Сборка и установка APK поверх существующего приложения: индекс и очередь сохраняются.
|
||
3. После обновления первый проход сам исправляет состояния элементов (решение 2) и
|
||
прекращает цикл на конкретном инстансе.
|
||
4. Разовое восстановление двух фото (решение 6) выполняется отдельно и не зависит от выкатки.
|