Obsidian Sync — benchmark (2026-06-21)
Замеры времени синка на ~2000 заметках через obsidian-sync CLI (тот же
classify/execute, что и в плагине), стек docker-compose.syncperf.yml
без векторного поиска. Каждый прогон bench.mjs дописывает секцию ниже —
так baseline и «после фикса» сравниваются в одном файле.
Сценарии: cold (первый пуш всех заметок), noop (повтор без изменений —
idle, где мёртвый кэш пере-хэширует всё), dry-noop (только classify),
small-change (правка K заметок), twoway-noop (--two-way без изменений).
Run: baseline (2026-06-21 09:13:32)
- commit
14b57753, notes 2002, repeats 3, touch 5, node v24.15.0 - stack: docker-compose.syncperf.yml (vector search OFF), CLI via tsx
- decomposition: noop − dry-noop ≈ execute/asset overhead = 0.04s
| scenario | median | min | max | runs | counts |
|---|---|---|---|---|---|
| cold | 3.30s | 3.30s | 3.30s | 1 | push0, pushed2000, assetsUp0, err0 |
| noop | 0.36s | 0.36s | 0.36s | 3 | push0, pushed0, assetsUp0, err0 |
| dry-noop | 0.32s | 0.32s | 0.35s | 3 | push0, pushed0, assetsUp0, err0 |
| small-change | 0.44s | 0.43s | 0.45s | 3 | push5, pushed5, assetsUp0, err0 |
| twoway-noop | 0.37s | 0.36s | 0.39s | 3 | push0, pushed0, assetsUp0, err0 |
Run: assets-unique (2026-06-21 09:28:26)
- commit
14b57753, notes 2002, repeats 3, touch 5, node v24.15.0 - stack: docker-compose.syncperf.yml (vector search OFF), CLI via tsx
- decomposition: noop − dry-noop ≈ execute/asset overhead = 0.11s
| scenario | median | min | max | runs | counts |
|---|---|---|---|---|---|
| cold | 231.79s | 231.79s | 231.79s | 1 | push0, pushed2000, assetsUp2000, err0 |
| noop | 0.67s | 0.66s | 1.01s | 3 | push0, pushed0, assetsUp0, err0 |
| dry-noop | 0.55s | 0.54s | 0.57s | 3 | push0, pushed0, assetsUp0, err0 |
| small-change | 1.05s | 0.93s | 1.12s | 3 | push5, pushed5, assetsUp0, err0 |
| twoway-noop | 0.73s | 0.72s | 0.76s | 3 | push0, pushed0, assetsUp0, err0 |
Findings (baseline)
Главное: ассеты — это вся боль cold-sync. Cold push 2000 заметок: без ассетов
3.3s, с ассетами (2000 × 1×1 PNG по 69 байт) — 231.8s. Рост ×70 при том,
что суммарный объём картинок ~138 КБ. Цена не в байтах — ~116ms на ассет чистого
оверхеда.
Корень: CLI/browser не шлют skipCommit:true при загрузке ассета. Сервер в
uploadnoteasset/resolve.go:108 делает PrepareLatestNotes (полный reload всех
заметок) на КАЖДУЮ загрузку, если не передан skipCommit. → 2000 reload-ов.
| фронтенд | skipCommit при upload |
cold-push ассетов |
|---|---|---|
plugin (src/env.ts:308) |
есть ✓ | не страдает (reload один раз в commit) |
CLI (src/sync/cli/env.ts:404-411) |
нет ✗ | ×N reload → 231s |
browser (src/sync/browser/index.ts:574-581) |
нет ✗ | ×N reload |
Важный вывод: baseline через CLI переоценивает cold-push живого плагина по
ассетам — у плагина skipCommit уже есть. Чтобы CLI-бенч отражал плагин (и чтобы
почистить реальный баг CLI/docs-sync), нужно добавить skipCommit:true в
cli/env.ts и browser/index.ts. Ожидание после фикса: cold ≈ 3–10s.
Второй баг (дедуп ассетов): дедуп по (noteId, localPath) — если N заметок
ссылаются на один файл, uploadAsset зовётся N раз для одного файла (N round-trip
- N локальных пере-хэшей; сервер контент-адресный → блоб пишется один раз, но
вызовы идут). Замеряется режимом--asset-mode shared(виден после фикса
skipCommit, иначе маскируется reload-ом).
idle (noop/dry/small) — дёшево даже с ассетами (0.5–1.1s, из них ~0.34s —
стартап tsx на запуск; в плагине его нет). Мёртвый mtime/hash-кэш на этом размере
заметок не виден; будет заметен на крупных заметках или по сети с задержкой.
Run: assets-unique-skipcommit (2026-06-21 09:37:30)
- commit
14b57753, notes 2002, repeats 3, touch 5, node v24.15.0 - stack: docker-compose.syncperf.yml (vector search OFF), CLI via tsx
- decomposition: noop − dry-noop ≈ execute/asset overhead = 0.07s
| scenario | median | min | max | runs | counts |
|---|---|---|---|---|---|
| cold | 8.80s | 8.80s | 8.80s | 1 | push0, pushed2000, assetsUp2000, err0 |
| noop | 0.41s | 0.40s | 0.42s | 3 | push0, pushed0, assetsUp0, err0 |
| dry-noop | 0.34s | 0.33s | 0.34s | 3 | push0, pushed0, assetsUp0, err0 |
| small-change | 0.52s | 0.51s | 0.52s | 3 | push5, pushed5, assetsUp0, err0 |
| twoway-noop | 0.39s | 0.39s | 0.40s | 3 | push0, pushed0, assetsUp0, err0 |
Подтверждение фикса skipCommit
cli/env.ts + browser/index.ts: добавлен skipCommit: true в uploadNoteAsset
(как уже было в плагине env.ts:308). cold: 231.79s → 8.80s (×26). Гипотеза
«per-upload PrepareLatestNotes» подтверждена эмпирически. Прогноз 3–10s сбылся.
Остаток 8.8s = 20 push-батчей (по 100, reload каждый) + 2000 upload round-trip
(теперь дёшево) + один финальный commit. Следующая цель cold — инкрементальный
reload на push (pushnotes/resolve.go:83).
Замечание: это косвенно подтверждает, что живой плагин в ~8.8s-режиме (у него
skipCommit есть), но прямой замер плагина ещё нужен (см. план).
Run: assets-shared-skipcommit (2026-06-21 09:39:11)
- commit
14b57753, notes 2002, repeats 3, touch 5, node v24.15.0 - stack: docker-compose.syncperf.yml (vector search OFF), CLI via tsx
- decomposition: noop − dry-noop ≈ execute/asset overhead = 0.05s
| scenario | median | min | max | runs | counts |
|---|---|---|---|---|---|
| cold | 5.97s | 5.97s | 5.97s | 1 | push0, pushed2000, assetsUp2000, err0 |
| noop | 0.38s | 0.36s | 0.38s | 3 | push0, pushed0, assetsUp0, err0 |
| dry-noop | 0.33s | 0.33s | 0.34s | 3 | push0, pushed0, assetsUp0, err0 |
| small-change | 0.52s | 0.51s | 0.54s | 3 | push5, pushed5, assetsUp0, err0 |
| twoway-noop | 0.38s | 0.38s | 0.39s | 3 | push0, pushed0, assetsUp0, err0 |
shared cold 5.97s < unique cold 8.80s: сервер контент-адресный, 1999 minio-записей
пропущены. Но assetsUp=2000 — дедуп-баг: один файл загружается 2000 раз (2000
round-trip + 2000 локальных пере-хэшей одного файла). Чистая трата ≈ 5.97 − 3.3 ≈ 2.7s.
Фикс дедупа (грузить уникальный блоб один раз) свёл бы shared cold к ~3.3s.
Прямой замер живого плагина (Obsidian)
Открыт tmp/syncperf-vault в Obsidian, сервер пуст, добавлена команда trip2g:sync,
триггер через ~/.local/bin/obsidian command id=trip2g:sync, длительность —
по числу notePaths (0 → 2000 на финальном commit), механизм — по docker-логам.
cold push 2000 заметок + 2000 ассетов: 6.0s. Из логов сервера за окно синка:
pushNotes: insert note— 2000 (все заметки),- полных reload (
latest noteloader: notes indexed) — 20 (= 2000/100 push-батчей), - reload на загрузку ассета — 0 (плагин шлёт
skipCommit:true).
| фронтенд | cold (2000 + assets) | reload-ов | механизм |
|---|---|---|---|
| CLI (баг) | 231.79s | ~2020 | PrepareLatestNotes на каждый upload |
CLI (фикс skipCommit) |
8.80s | ~20 | skip per-upload reload |
| plugin (живой) | 6.0s | 20 | skipCommit уже был (env.ts:308) |
Вывод (эмпирический, не инференс): живой плагин per-asset-reload багом не страдает.
Оставшаяся цена cold (20 reload на push-батчи) — общая для всех; цель — инкрементальный
reload (pushnotes/resolve.go:83). Также в логах: write pool exhausted ... will block ×2
(контеншн на write-пуле под всплеском — отдельный мелкий сигнал).
Warm in-process (без стартапа tsx, 15 итераций)
scripts/syncperf/warmbench.mts — времена самого classify без ~0.34s оверхеда node/tsx
на запуск (плагин его не платит — долгоживущий процесс):
| операция (2000 заметок) | median | min | max |
|---|---|---|---|
| classify (idle, full) | 38.0ms | 32.0 | 67.9 |
| local hash all 2000 («мёртвый кэш») | 17.9ms | 15.6 | 20.8 |
| fetch server hashes | 10.5ms | 9.6 | 14.8 |
Вывод: на 2000 мелких заметок (localhost) idle-классификация ~38ms, пере-хэш всех
файлов ~18ms. Мёртвый mtime/hash-кэш здесь незначим. Он станет заметен на крупных
заметках (стоимость ∝ байтам) или по сети с задержкой (10ms × RTT-фактор). Числа из
CLI-бенча (noop ~0.4s) — это почти целиком стартап tsx, а не работа синка.