chore(openspec): предложить fix-album-sync-churn-and-degradation и уточнить спеку phone-album-sync

Изменение описывает три дефекта двусторонней синхронизации: повторную
загрузку собственного превью, удаление серверной копии до подтверждения
замены и повторную разгрузку только что восстановленного оригинала.
This commit is contained in:
iu
2026-10-20 20:32:20 +03:00
parent cc85d792ea
commit 46d54b4b12
5 changed files with 450 additions and 0 deletions
@@ -0,0 +1,171 @@
# 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) выполняется отдельно и не зависит от выкатки.