Files
ds-galo/openspec/changes/safe-album-offload/design.md
T
iu 1eb1177273 feat(android): статус синхронизации на карточке и безопасная разгрузка
Карточка альбома устройства получает третью строку: метку режима («Копии»/
«Перенос») и ход загрузки. Процент считается как (uploaded + running) / total
для обоих синхронизируемых режимов, пульсирует только при активной загрузке,
показывает очередь, ожидание Wi-Fi и ошибку. В режиме переноса карточка
показывает и карту памяти, и облако — режимы различает метка, а не значки.

Разгрузка оригиналов теперь требует подтверждённой серверной копии в текущем
целевом альбоме: устаревший serverPhotoId перепроверяется по альбому, смена
цели переносит уже загруженные фото и отклоняется, если перенести нельзя.
Потеря серверной копии видна на карточке и на экране альбома, автозатирания
превью нет.

Изменения OpenSpec: android-album-card-sync-status, safe-album-offload.
2026-10-16 18:36:51 +03:00

7.9 KiB
Raw Blame History

Design

Context

См. proposal.md — Why. Ниже — что в коде определяет подход.

  • SyncCoordinator.reconcile получает serverChecksums(link.serverAlbumId) и вычисляет livePhotoIds = serverChecksums.values.toSet(), но SyncPlanner.plan и offloadUploaded этот набор не используют: первый смотрит только на indexed.serverPhotoId != null, второй — на entity.serverPhotoId != null. reflectLostCopies умеет лишь перекрасить PREVIEW ↔ ORPHANED и тоже по livePhotoIds.
  • Разгрузка вызывается из трёх мест: SyncCoordinator.offloadUploaded (режим C), UploadWorker.recordProvenance (сразу после успешной загрузки) и DeviceAlbumViewModel.freeSpace (ручное освобождение по согласию системы).
  • AssetOffloader.offload(asset) не требует доказательств, что копия жива.
  • SyncRepository.link перезаписывает serverAlbumId; смену цели обслуживают DeviceAlbumViewModel.setTarget и createAlbum — оба просто линкуют и запускают reconcile.
  • Серверный перенос фото есть: SyncRemote.movePhotos → POST /api/photo-batch/move, который пропускает удалённые фото и возвращает число перенесённых, не сообщая об ошибке за пропуск.
  • LocalAssetState.ORPHANED уже вычисляется и показывается перечёркнутым облаком на элементе; счётчика и заметного предупреждения нет.

Goals / Non-Goals

Goals:

  • Сделать невозможной разгрузку без подтверждённой копии в текущем целевом альбоме.
  • Не терять связь с уже загруженными фото при смене целевого альбома.
  • Сделать потерю серверной копии видимой и оставить её устранение явным действием.

Non-Goals:

  • Восстановление уже потерянных копий из корзины сервера.
  • Предупреждение об удалении серверного альбома из веб-клиента: сервер и веб не знают о зависимости альбома устройства.
  • Ручные загрузки в серверный альбом и их режимы.

Decisions

Единственный источник доверия — живой список фото целевого альбома. И plan, и разгрузка опираются на livePhotoIds, уже полученный в reconcile. Альтернатива — отдельный запрос «фото существует» — отклонена: лишние round-trip и рассинхрон.

Планировщик лечит устаревшую запись, а не игнорирует её. Ветка «записанный photoId» срабатывает только если photoId ∈ livePhotoIds; иначе ассет проходит обычный путь: дедуп по контрольной сумме, а при отсутствии — загрузка. Удаление серверных фото в конце плана тоже ограничено livePhotoIds, чтобы не тронуть чужую копию.

Разгрузка требует доказательства в подписи. AssetOffloader.offload(asset, liveServerPhotoIds) и новый исход OffloadResult.NotOnServer, если копии нет. Так инвариант живёт в одном месте, а вызывающий обязан доказать, что копия жива:

  • offloadUploaded передаёт livePhotoIds из reconcile;
  • recordProvenance передаёт setOf(photo.id) — только что созданное фото;
  • freeSpace сначала читает serverChecksums текущего линка и передаёт его значения; при ошибке не разгружает ничего.

Смена цели — транзакция «перенести, проверить, переключить». SyncCoordinator.retarget переносит фото провенанса этого альбома в новый альбом, затем перечитывает новый альбом и убеждается, что все переносимые копии на месте; только тогда переключает связь. Если прежний альбом недоступен или копии не доехали — связь остаётся прежней, результат возвращается как отказ. Альтернатива «переключить и попробовать потом» отклонена: именно она дала инцидент.

Уже потерянные копии не блокируют смену цели, но сообщаются. Фото, которых нет ни в прежнем альбоме, ни в новом (например, давно осиротевшие), перенести нельзя; они считаются и возвращаются в результате, чтобы пользователь знал, но не мешают переключить альбом.

Потеря копии показывается из ORPHANED, а не из проверки существования альбома. Существование целевого альбома при офлайне недоказуемо, а ORPHANED выставляется только после успешного чтения альбома. Один новый запрос GROUP BY deviceAlbumId по state = 'ORPHANED' даёт счётчик для карточки и баннера; на карточке потеря копии перекрывает прогресс операции.

Risks / Trade-offs

  • [Перенос пропускает удалённые фото без ошибки] → проверяем результат чтением нового альбома, а не ответом move; ненулевое расхождение — отказ.
  • [Лишние serverChecksums в freeSpace и retarget] → это редкие явные действия, не цикл; при ошибке сети ничего не разгружаем и не переключаем.
  • [Планировщик начинает перезаливать содержимое, если сервер не отдал контрольную сумму] → serverChecksums падает вместе с reconcile (исключение до применения плана), поэтому «пустой живой список из-за ошибки» не доходит до загрузок.
  • [Счётчик ORPHANED в карточке устаревает между проходами] → приемлемо: он обновляется тем же потоком Room, что и всё представление, и означает «на последнем успешном проходе».

Migration Plan

Схема Room не меняется; миграций нет. Откат — обычная сборка без изменения.

Open Questions

  • Нужен ли отдельный сценарий «перенести потерянные копии из прежнего альбома» как ручное действие, когда включённый режим уже сменился — отдельным изменением.