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

93 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
- Нужен ли отдельный сценарий «перенести потерянные копии из прежнего альбома» как ручное
действие, когда включённый режим уже сменился — отдельным изменением.