Obsidian Sync — live-pull: дизайн (2026-06-21)
TL;DR
- Live-pull — это UX, а не производительность.
notePathsсейчас НЕ медленный:
latest_content_hash— сохранённая колонка, выборка всех 2000 ≈ 10.5ms / 80KB
(бенч 2026-06-21). Страх «медленный notePaths» устарел (был до precomputed-хэша). - Live-pull заменяет таймер, а не запрос.
notePathsвсё равно нужен для cold-start,
догона после реконнекта и определения push/conflict (события несут только серверную
сторону). Поэтому polling не убираем — делаем реже (reconcile-бэкстоп ~5-10 мин) +
SSE для мгновенной реакции. Это ровно то, что ты сказал. - Выигрыш: sub-second свежесть бейджа/контента вместо лага до 60s (важно для
multi-device / агентов). Трафик idle-WAN тоже падает, но на флаки-связи реконнект =
полный fetch, так что чистого perf-выигрыша может не быть. - Блокер №1 (backend, критичный):
notebusпадает сsend on closed channelи
роняет весь монолит, как только реальный клиент подключается/отключается. Без этого
фикса live-pull включать нельзя.
Что уже есть / чего нет
| Состояние | |
|---|---|
Backend подписка noteChanges (notebus + resolver + schema) |
✅ готова end-to-end |
| Публикация из commitNotes/pushNotes/updateNotes/hideNotes | ✅ есть (нюанс: gating, см. ниже) |
| Плагин-потребитель (SSE-клиент) | ❌ нет (нет операции в operations.graphql, нет getReader) |
Курсор для догона пропущенного (since/afterId) |
❌ нет; NoteChangesFilter = только include/exclude |
| Bus — потеря событий | буфер 64, при переполнении молча дропает (notebus.go:99-101) |
Событие NoteUpsertEvent{path, eventType(create|update), versionId, title, noteView, changedHtmlSelectors},
NoteHideEvent{path, pathId}. Хэша в событии нет — удалённый хэш берём из
noteView.content (есть в payload) или дотягиваем notePaths(filter:{paths}).
Must-fix backend (до включения live-pull)
| # | Баг | Severity | Фикс |
|---|---|---|---|
| R1 | notebus.go:78 close(sub.ch) вне лока, а Publish шлёт в канал после RUnlock (:97-98) → send on closed channel → паника валит монолит. select/default НЕ спасает (паника при любом буфере), recover() нет. Срабатывает на каждом disconnect во время save. |
CRITICAL | удалить close(sub.ch) — резолвер и так выходит по ctx.Done(), канал соберёт GC. ~1 строка |
| R5 | Подписка под checkapikey без canreadnote; buildNoteChangeItems не фильтрует видимость → события (title/url/HTML-форма) текут любому держателю ключа, включая черновики, которых нет даже в AllVisibleNotePaths. Это хуже, чем notePaths (тот фильтрует hidden_by). |
HIGH (privacy) | в buildNoteChangeItems отбрасывать невидимые заметки (тот же гейт, что AllVisibleNotePaths). ~5 строк |
| R4 | main.go:1277 if skipWebhooks { return nil } стоит ДО publish (:1300-1316) → синки со skip_webhooks не эмитят события. Плагин обычным путём (pushNotes+commitNotes) флаг НЕ ставит → свои эхо получает (это ок). Касается только агентов со skip_webhooks. |
MED | поднять publish-блок выше гейта (webhook-доставка остаётся под гейтом) |
План по фазам
Фаза 0 (backend, блокер): R1 (notebus, ~1 строка) + R5 (видимость, ~5 строк). Без R1 не подключать клиента.
Фаза 1 — badge-only live-pull (80% пользы, 0 риска для корректности):
subscription NoteChangesвoperations.graphql.- Новый
obsidian-sync/src/sync/LivePullConnection.ts— pure-JS SSE:fetch+getReader()
(graphql-request стримить не умеет), POST/_system/graphqlAccept: text/event-stream+
X-API-Key, reconnect с backoff[3,6,12,30]s, health-check 60s (abort→reconnect, если нет
данных, включая keepalive). Парсинг построчноevent:/data:какassets/ui/sse/sse.ts:95-128
(НЕ split по\n\n— это в плане было неверно). - На событие — двигать существующий бейдж (как
checkForPendingChanges). Один cold-start
FetchServerHashesдля сидирования. Заменить 60ssetIntervalна reconnect-driven reconcile. - Бейдж — advisory: даже рассинхрон стрима безопасен, реальный
syncDirectoryклассифицирует с нуля.
Фаза 2 — catch-up + порядок (корректность догона):
- На каждый connect/reconnect — один
catchUpPull: ставитisSyncing(сериализует против
событий через существующую очередьpendingLivePull), гоняетclassifySync, выполняет
только pulls, потом дренит очередь. Закрывает gap после оффлайна и дропов буфера. - Низкочастотный (~5-10 мин) полный reconcile как бэкстоп против потери событий (bus лоссовый).
- Ключи коннекций — составные
apiUrl+path(не только apiUrl — иначе коллизия двух syncDir).
Фаза 3 — авто-pull контента (рискованная, опционально):
- Тянуть только когда
classifyFile === "pull"(т.е.localHash === lastSyncedHash— локальное
не трогали; либо новый файл без локальной копии).conflict— никогда не писать, только
бейдж + Notice.NoteHideEvent— через существующийServerDeletedModal, никогда не авто-удалять. - КРИТИЧНО: НЕ переиспользовать
executePlan— у него безусловные asset-пассы поunchanged
(execute.ts:122-129uploadMissingAssetsForNotesпушит на сервер + триггеритcommitNotes).
Авто-pull должен зватьexecutePulls+downloadAssetsForNotesнапрямую (~15 строк, структурно
не может пушить). - Эхо своих пушей: классификация по хэшу →
unchanged. Но классифицировать против свежего
in-memory syncState, не перечитывать из IndexedDB (окно между push HTTP иsaveSyncState).
Ключевые правила безопасности
- Авто-pull пишет файл только при
classifyFile === "pull".conflict→ бейдж, ждать ручного синка. - Авто-pull строить на
executePulls/downloadAssetsForNotes, неexecutePlan(иначе латентный пуш). - Bus лоссовый и без курсора → периодический полный reconcile обязателен как бэкстоп (не презентовать live-pull как «убрали polling совсем»).
- Reconnect: стрим открываем первым,
catchUpPullсериализован с применением событий (общая очередь +isSyncing). NoteHideEvent→ модалка подтверждения, не авто-delete.
Открытые вопросы
- Делать ли настоящий курсор
notePathsDelta(sinceVersion)(новая колонка/запрос, как вobsidian_sse_pulls.md) — даёт gap-free догон без полного fetch. Отложено на потом;note_versions.id— готовый монотонный seq, но запроса «изменения с версии N» нет. - Серверный keepalive (
transport.SSE{KeepAlivePingInterval}) — подтвердить, что шлётся ≤30s, иначе health-check будет зря реконнектить каждые 60s.
Файлы
Backend: internal/notebus/notebus.go:78, internal/graph/note_changes.go:8-35, cmd/server/main.go:1277,1300-1316.
Плагин: новый obsidian-sync/src/sync/LivePullConnection.ts; operations.graphql; src/main.ts (lifecycle, autoPull, catchUpPull, settings, замена 60s таймера); src/sync/types.ts, src/types.ts (типы события + SyncDir patterns); src/sync/execute.ts (executePulls/downloadAssetsForNotes напрямую).
Источник: воркфлоу-аудит 2026-06-21 (verify + design + tradeoff + adversarial critique). Связано: obsidian_sync_2026-06-21 (общий разбор), obsidian_sync_bench_2026-06-21 (бенч).