Skip to content

Commit 0410328

Browse files
authored
refactor(sync): split SyncManager into 3 phases, add folder sync and update docs (#112)
1 parent 14829cb commit 0410328

2 files changed

Lines changed: 76 additions & 98 deletions

File tree

docs/sync/manager.md

Lines changed: 42 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,58 @@
11
# Модуль синхронизации: Оркестрация SyncManager
22

3-
Центральным компонентом синхронизации данных между локальным хранилищем (SQLite/Room) и удаленным облаком (Firebase Storage) является класс `SyncManagerImpl`. Система спроектирована по принципу **Offline-First**: пользователь имеет полный доступ к чтению и модификации заметок без активного сетевого соединения, а слияние состояний происходит асинхронно при восстановлении сети.
3+
Центральным компонентом синхронизации данных между локальным хранилищем (SQLite/Room) и удаленным облаком (Firebase Storage) является интерфейс `SyncManager` и его реализация `SyncManagerImpl`.
44

5-
## 1. Алгоритм синхронизации метаданных (Двухфазный коммит)
5+
Система спроектирована по принципу **Offline-First**: пользователь имеет полный доступ к чтению и модификации данных без активного сетевого соединения. Слияние состояний происходит асинхронно при восстановлении сети.
66

7-
Процесс синхронизации запускается вызовом функции `sync(userId: String)` и состоит из двух строго изолированных фаз: синхронизации текстовых данных заметок (метаданных) и синхронизации бинарных вложений (медиафайлов).
7+
Для соблюдения принципа единственной ответственности (SRP) сложная логика монолита разделена на три изолированные фазы: выгрузка (`SyncPusher`), загрузка (`SyncPuller`) и локальная очистка (`SyncCleaner`).
8+
9+
## 1. Алгоритм синхронизации (Трехфазный коммит)
10+
11+
Процесс запускается вызовом функции `sync(userId: String)` и состоит из последовательной обработки трех типов данных: Папки (Folders) ➔ Заметки (Notes) ➔ Бинарные медиа (Media).
812

913
```mermaid
1014
graph TD
11-
A[Старт sync_userId] --> B[Фаза 1: Загрузка метаданных из Firebase]
12-
B --> C[Сравнение локальных и удаленныхUpdatedAt]
13-
C --> D{Есть конфликты?}
14-
D -- Удаленный новее --> E[Скачать JSON -> Обновить Room]
15-
D -- Локальный новее --> F[Собрать JSON -> Выгрузить в Firebase]
16-
D -- Одинаковые/Нет изменений --> G[Пропустить]
17-
E --> H[Фаза 2: Сверка бинарных медиафайлов]
18-
F --> H
19-
G --> H
20-
H --> I[Выгрузка локальных медиа с isSynced = false]
21-
I --> J[Скачивание недостающих медиа из Firebase]
22-
J --> K[Завершение сессии: Инициализация Idle]
15+
A[Старт sync_userId] --> B[SyncPusher: Выгрузка локальных изменений]
16+
B --> B1[Push Папок]
17+
B1 --> B2[Push Заметок]
18+
B2 --> B3[Push Медиа]
19+
20+
B3 --> C[SyncPuller: Скачивание обновлений из облака]
21+
C --> C1[Pull Папок]
22+
C1 --> C2[Pull Заметок]
23+
C2 --> C3[Pull Медиа]
24+
25+
C3 --> D[SyncCleaner: Очистка удаленных данных]
26+
D --> E[Удаление отсутствующих папок, заметок и медиа в Room]
27+
E --> F[Завершение сессии: SyncState.Success]
2328
```
29+
### Детальные шаги выполнения:
30+
Фаза Push (Выгрузка):
31+
Через SyncPusher менеджер запрашивает все локальные записи (папки, заметки, медиа), имеющие флаг isSynced = false. Если локальная сущность помечена как удаленная (isDeleted = true), на сервер отправляется запрос на удаление соответствующего файла, после чего запись физически стирается из Room (hard delete). Остальные модифицированные записи формируются в JSON-пакеты (или потоки байтов для медиа) и отправляются в Firebase Storage. После успеха статус меняется на isSynced = true.
2432

25-
###Детальные шаги выполнения:
26-
Запрос облачного слепка: Через CloudDataSource.listNoteMetadata(userId) запрашивается список всех файлов заметок, находящихся в облачной папке users/$userId/notes. Каждая запись содержит key (путь к файлу) и updatedAt (временная метка последнего изменения на стороне Firebase).
33+
Фаза Pull (Скачивание):
34+
SyncPuller запрашивает облачные слепки (списки метаданных) для папок, заметок и медиафайлов пользователя. Сравнивая ключи из облака с ID локальных записей, система определяет недостающие элементы. Эти элементы скачиваются, парсятся из JSON и сохраняются в локальную базу данных (через SyncDaoContainer).
2735

28-
Получение локального слепка: Параллельно из NoteDao запрашиваются все локальные записи (включая помеченные как удаленные через флаг isDeleted).
36+
Фаза Clean (Очистка):
37+
SyncCleaner берет множества (Sets) ID элементов, полученных на этапе скачивания, и вычисляет разницу с локальной БД. Если локальная запись присутствует в Room, но отсутствует в облачном слепке, она считается удаленной с другого устройства и физически стирается локально (для медиа удаляются и сами файлы из памяти устройства).
2938

30-
Анализ расхождений:
39+
## 2. Управление состояниями (SyncState)
40+
Для реактивного отображения статуса синхронизации в UI (например, для показа спиннера загрузки) менеджер предоставляет StateFlow<SyncState>.
41+
##### Возможные состояния:
3142

32-
Если локальная заметка имеет флаг isDeleted = true и isSynced = false, на сервер отправляется запрос на удаление соответствующего JSON-объекта, после чего запись физически стирается из Room.
43+
SyncState.Idle — синхронизация не запущена.
3344

34-
Если updatedAt в Firebase строго больше, чем локальный updatedAt, локальная запись перезаписывается скачанными данными.
45+
SyncState.Syncing — активный процесс обмена данными (Push/Pull/Clean).
3546

36-
Если локальный updatedAt новее, формируется свежий JSON-пакет и отправляется в Firebase Storage.
47+
SyncState.Success — все три фазы успешно завершены.
3748

38-
##2. Модель полиморфной сериализации заметок
39-
Поскольку заметка (Note) в приложении имеет динамическую структуру и может состоять из произвольного набора контентных блоков (текст, картинки, файлы, ссылки), для её сохранения на сервере применяется полиморфная сериализация на базе kotlinx.serialization.
49+
SyncState.Error — процесс прерван с ошибкой (содержит сообщение об ошибке).
4050

41-
Преобразование данных изолировано в классе NoteEntityJsonConverter. Каждая заметка упаковывается в текстовый JSON-файл следующей структуры:
51+
## 3. Полиморфная сериализация данных
52+
Поскольку заметки (Note) и папки (Folder) имеют динамическую структуру, для их сохранения на сервере применяется сериализация на базе kotlinx.serialization. Преобразования изолированы в классе SyncMappers (NoteEntityJsonConverter и FolderEntityJsonConverter).
4253

54+
Каждая сущность упаковывается в текстовый JSON-файл.
55+
### Пример структуры:
4356
```json
4457
{
4558
"id": "e8ba5411-137a-4c28-9642-e10bcbf14291",
@@ -54,14 +67,13 @@ graph TD
5467
{
5568
"type": "com.itlab.domain.model.ContentItem.Text",
5669
"id": "txt_01",
57-
"text": "Вводная часть по оптимизации моделей.",
58-
"format": "PLAIN"
70+
"text": "Вводная часть по оптимизации моделей."
5971
},
6072
{
6173
"type": "com.itlab.domain.model.ContentItem.Image",
6274
"id": "img_01",
6375
"source": {
64-
"remoteUrl": "users/firebase_user_uid_123/media/img_01",
76+
"remoteUrl": "users/firebase_user_uid_123/media/e8ba5411-137a-4c28-9642-e10bcbf14291_img_01",
6577
"localPath": "/data/user/0/com.itlab.notes/files/media/img_01"
6678
},
6779
"mimeType": "image/png"
@@ -70,57 +82,5 @@ graph TD
7082
}
7183
```
7284

73-
###Особенности маппинга:
74-
Поле content: В локальной БД Room для оптимизации поисковых запросов и быстрого рендеринга списков content хранится в виде плоской строки (NoteEntity.content). При сериализации для Firebase SyncManager парсит или собирает полноценный массив contentItems.
75-
76-
Изоляция путей: При передаче в Firebase локальные абсолютные пути устройства (localPath) очищаются или игнорируются на других устройствах, а привязка идет по уникальному remoteUrl, строящемуся по маске users/$userId/media/$mediaId.
77-
78-
##3. Фаза синхронизации медиаресурсов
79-
После завершения сверки текстовых метаданных SyncManagerImpl переходит к обработке бинарных данных (изображения и вложенные файлы), хранящихся в таблице media.
80-
81-
Исходящий поток (Upload): Выбираются все записи из MediaDao.getAllMedia(), у которых isSynced = false. Менеджер открывает inputStream локального файла по адресу localPath и стримит байты напрямую в референс Firebase Storage: rootRef.child(remoteUrl). После успешного завершения статус записи в БД меняется на isSynced = true.
82-
83-
Входящий поток (Download): На основе метаданных, полученных из cloudDataSource.listMediaMetadata(userId), вычисляется разница между файлами на сервере и локальным диском. Для каждого скачиваемого файла:
84-
85-
В системной директории context.filesDir создается папка media/ (если отсутствует).
86-
87-
Выделяется файл с именем, соответствующим чистому ID медиаресурса: File(context.filesDir, "media/$actualMediaId").
88-
89-
Данные скачиваются из облака, и в таблицу media вносится новая запись MediaEntity с типом "IMAGE" или "FILE" в зависимости от его Mime-Type.
90-
91-
92-
93-
### 📂 Создай файл: `docs/sync/workmanager.md`
94-
95-
```markdown
96-
# Модуль синхронизации: Фоновые задачи (WorkManager)
97-
98-
Для обеспечения гарантированной доставки изменений на сервер даже в случае закрытия приложения пользователем, вся инфраструктура запуска синхронизации вынесена из главного процесса UI в системный планировщик **Jetpack WorkManager**.
99-
100-
## 1. Планирование задач через WorkManagerSyncScheduler
101-
102-
Запуск фонового процесса инкапсулирован в классе `WorkManagerSyncScheduler`, реализующем доменный интерфейс `SyncScheduler`. Вместо хаотичного вызова корутин из `ViewModel`, отправка триггерится системными намерениями.
103-
104-
### Критерии и ограничения запуска (Constraints):
105-
Для минимизации расхода заряда батареи и исключения ошибок холостых сетевых запросов настраиваются жесткие ограничения:
106-
107-
```
108-
```kotlin
109-
val constraints = Constraints.Builder()
110-
.setRequiredNetworkType(NetworkType.CONNECTED) // Обязательное наличие интернета
111-
.build()
112-
```
113-
114-
115-
###Обеспечение уникальности выполнения:
116-
Синхронизация тяжелых файлов и JSON-структур требовательна к транзакционности. Для предотвращения состояния гонки (Race Condition), когда несколько триггеров пытаются одновременно писать данные в одну и ту же учетную запись Firebase, используется механизм уникальных воркеров:
117-
118-
```kotlin
119-
workManager.enqueueUniqueWork(
120-
"sync_work_$userId", // Уникальное имя воркера, привязанное к UID пользователя
121-
ExistingWorkPolicy.REPLACE, // Стратегия: если прошлый воркер еще работает, заменить его новым
122-
syncRequest
123-
)
124-
```
125-
126-
Использование ExistingWorkPolicy.REPLACE гарантирует, что если пользователь совершил серию быстрых изменений в заметках, цепочка промежуточных воркеров прервется, и запустится актуальный процесс, содержащий последний слепок локальной БД.
85+
### Изоляция путей
86+
При передаче медиафайлов в Firebase локальные абсолютные пути устройства (localPath) очищаются или игнорируются на других устройствах. Привязка идет по уникальному remoteUrl, строящемуся по маске users/$userId/media/${noteId}_${mediaId}. При входящем потоке (Download) скачанные данные сохраняются в context.filesDir/media/, и таблица Room обновляется актуальным локальным адресом.

0 commit comments

Comments
 (0)