Skip to content

fix(syncer,daemon): avoid full-volume ignore and cache work on targeted local syncs - #336

Merged
isac322 merged 3 commits into
mainfrom
isac322/fix-local-change-sync
Nov 4, 2026
Merged

isac322 merged 3 commits into
mainfrom
isac322/fix-local-change-sync

Conversation

@isac322

@isac322 isac322 commented Nov 4, 2026

Copy link
Copy Markdown
Member

TL;DR

  • Local file edits in large workspaces (tens of thousands of files with .bdriveignore rules) now upload within one scan interval instead of stalling for minutes.
  • .bdriveignore matching avoids backtracking across every byte offset of every path, and backfillOps and materialize skip ignore checks for files that already match the local cache.
  • Each sync cycle reuses its in-memory journal fold when peer journals on disk have not changed, instead of reading and sorting all device journals up to three times.
  • The local folder watcher prunes .git, .bdrive, nested mounts, and ignored directories, skips loading the state cache during echo suppression when no pre-fence events are pending, and retries targeted scan paths once after a transient read failure.

Summary

User-requested fix after v0.18.2 (844e708dc3a5a85843f8da4cf9d4949bd931ff54). Even with targeted watcher scans enabled, local file edits in large workspaces appeared stuck because post-scan stages still ran full-volume ignore regex evaluation and repeated journal folds on every cycle, while the macOS watcher watched ignored trees and doubled open file descriptors during ENOENT rebuilds. This PR removes the per-cycle full-volume bottlenecks and hardens watcher pruning, echo suppression, and transient scan retries.

Root cause

In internal/syncer/ignore.go, unanchored .bdriveignore patterns compiled with a leading (^|.*/) alternation, which prevented Go's regexp package from using a literal prefix or one-pass execution and forced tryBacktrack across every byte position of every path. In internal/syncer/syncer.go, backfillOps and materialize evaluated Filter.Skip on every target entry before checking whether the entry already matched cache[rel], and cycleLocked parsed and replayed all device journals up to three times per cycle. In internal/daemon/daemon.go, suppressMaterializationEchoes loaded the full state cache JSON on every check after the first watcher event even when the pending queue was empty, startLocalFolderWatcher registered watches on .git, .bdrive, nested mounts, and ignored directories, and rebuildWatcher opened a replacement watcher before closing the previous one on EMFILE/ENFILE.

Design

compile in internal/syncer/ignore.go now compiles unanchored rules with (?:^|/) and (?:/.*)?$, extracts literal segment and prefix prefilters at compile time, and evaluates rules in reverse order so the last matching rule returns immediately. In internal/syncer/syncer.go, backfillOps and materialize check whether a regular file's cached blob and mode already match the replayed target (and the path is not read-only) before calling Filter.Skip. cycleLocked scans .bdriveignore ops directly when hadCachedIgnore is true without mountJoining, and reuses the initial AllOps() slice across conflict detection and materialization whenever pull and loadScope did not modify peer journals on disk.

In internal/daemon/daemon.go, suppress snapshots pre-fence candidate events under pendingMu, evaluates keep outside the lock with lazy sync.Once cache loading, and drops only entries whose sequence number did not advance while unlocked. startLocalFolderWatcher prunes reserved directories, nested mounts outside rootList, and Filter.PruneDir directories both at startup and on runtime directory creation, re-walking the root when .bdriveignore changes. If rebuildWatcher hits EMFILE or ENFILE, it closes the old watcher first to release file descriptors before retrying. If a targeted ScanHint walk encounters a transient stat, readlink, or blob-hash error, Session.scan records the path in Result.RetryPaths and runVolumeWorker re-queues it once for the next scan interval.

Changes

  • internal/syncer/ignore.go: add precomputed literal/segment matchers (pattern.match), replace (^|.*/) with (?:^|/), and evaluate ignoredBy in reverse order.
  • internal/syncer/syncer.go: check cache convergence before Filter.Skip in backfillOps and materialize, reuse loaded journal ops across cycleLocked, and return Result.RetryPaths for transient targeted-scan read failures.
  • internal/daemon/daemon.go: evaluate echo suppression outside pendingMu with lazy cache loading, prune reserved/nested/ignored directories in startLocalFolderWatcher, close the old watcher before retrying an EMFILE/ENFILE rebuild, and re-queue Result.RetryPaths once.
  • internal/syncer/targeted_safety_test.go, internal/daemon/watchpoll_test.go, internal/daemon/watch_safety_test.go: add unit and integration coverage for QA-01 through QA-09.
  • architecture/cli-sync.md, CHANGELOG.md: update the sync engine class diagram and Unreleased release notes.

Architecture changes

In architecture/cli-sync.md, Result gains +RetryPaths []string for transient targeted-scan read failures, and localFolderWatcher adds a dependency on Filter (PruneDir on watched trees).

flowchart LR
    Session["<b>Session</b><hr/>+Cycle(ctx) Result<br/>+CycleWithScanHint(ctx, hint) Result"]
    Result["<b>Result</b><hr/>+LocalOps +PulledOps<br/>+Conflicts +Adopted +Pruned +Materialized<br/>+Reverted []string<br/>✅ +RetryPaths []string<br/>+Inbound []store.InboundEvent<br/>+Pushed +Offline +OfflineErr<br/>+ReadOnly +NoAccess +AccessErr"]
    Filter["<b>Filter</b><hr/>+Skip(rel) bool<br/>+SkipUp(rel) bool<br/>+PruneDir(rel) bool<br/>+AcceptRules(text)"]
    volumeWorker["<b>volumeWorker</b><hr/>-string expectedRemote<br/>-context.CancelFunc cancel<br/>-chan wakeCh<br/>-chan done"]
    localFolderWatcher["<b>localFolderWatcher</b><hr/>-chan wake<br/>-chan err<br/>+close()<br/>+take(root) localWatchBatch<br/>+fence() sequence<br/>+suppress(root, fence, keep)"]

    Session --> Result
    Session --> Filter : SkipUp on scan, Skip on materialize
    volumeWorker --> Session : one Cycle per tick
    volumeWorker --> localFolderWatcher : local filesystem wake and safety fallback
    localFolderWatcher -.->|"✅ PruneDir on watched trees"| Filter
Loading

QA checklist

ID Behavior Test Tier
QA-01 Fast .bdriveignore matcher agrees with the reference regex matcher across anchored, unanchored, dirOnly, *, **, ?, escaped characters, and ! negations, and completes 5,000 deep paths across 20 rules under 500ms TestFilterFastEquivalenceAndLargeScale unit
QA-02 backfillOps and materialize skip Filter.Skip for converged cache entries while preserving read-only drift reverts and respecting ignored read-only paths TestMaterializeAndBackfillSkipFilterForConvergedCache integration
QA-03 Local edits while the hub SSE stream is open journal and push without losing pending changes TestWorkerRemoteWakePreservesPendingLocalEdit, TestWorkerRemoteTimerPreservesQueuedLocalChanges integration
QA-04 Echo-only wakes do not trigger extra discovery or hub requests TestWorkerSuppressesLateMaterializationEchoes integration
QA-05 Transient child ENOENT recovers without stopping the watcher, and EMFILE/ENFILE during rebuild releases the old watcher before retrying TestWatcherRebuildsOnChildENOENT, TestWatcherRecoversFromAsyncChildENOENT integration
QA-06 .git, .bdrive, and .bdriveignore-pruned directories are skipped at startup and runtime creation, and removing a rule watches the newly un-ignored directory TestLocalFolderWatcherPrunesIgnoredAndReservedDirs integration
QA-07 suppressMaterializationEchoes skips LoadCache when no pre-fence events are pending, evaluates keep outside pendingMu, and preserves events merged while keep runs TestSuppressMaterializationEchoesSkipsCacheLoadWhenNoPendingEvents unit
QA-08 Transient read failures during a targeted ScanHint populate Result.RetryPaths and are retried on the next scan interval without waiting for the 60s safety scan TestTargetedScanRecordsRetryPathsOnTransientReadFailure, TestWorkerRequeuesRetryPathsFromTargetedScan integration
QA-09 Large-workspace (66,486 files, 26 ignore rules) local edit, create, nested create, atomic replace, and delete upload within one scan/push cycle Executed in isolated 66,486-file harness e2e
QA-10 Existing syncer, daemon, and CLI suites pass without regression go test -race ./internal/syncer ./internal/daemon integration

Verification

Commands run:

  • go vet ./...: passed
  • go test -race -v ./internal/syncer -run 'TestFilterFastEquivalenceAndLargeScale|TestMaterializeAndBackfillSkipFilterForConvergedCache|TestTargetedScanRecordsRetryPathsOnTransientReadFailure': passed (2.49s)
  • go test -race -v ./internal/daemon -run 'TestSuppressMaterializationEchoesSkipsCacheLoadWhenNoPendingEvents|TestLocalFolderWatcherPrunesIgnoredAndReservedDirs|TestWorkerRequeuesRetryPathsFromTargetedScan': passed (2.10s)
  • go test -count=1 ./internal/syncer ./internal/daemon ./cmd/... ./internal/config ./internal/journal ./internal/remote ./internal/store ./internal/autostart ./internal/agenthooks ./internal/secrets ./internal/templates: passed

Old vs new (same test code):

QA ID main (b7c6f2dc8651) this branch (93ebca8b094d) Environment
QA-01 (TestFilterFastEquivalenceAndLargeScale) fails: fast filter workload took 4.33345875s passes (0.12s with -race) darwin/arm64, Go 1.26
QA-02 (TestMaterializeAndBackfillSkipFilterForConvergedCache) fails: nestedMiss=map[locked:true] passes (0.01s) darwin/arm64, Go 1.26
QA-06 (TestLocalFolderWatcherPrunesIgnoredAndReservedDirs) fails: pruned directory .git was added to watcher passes (0.01s) darwin/arm64, Go 1.26
QA-07 (TestSuppressMaterializationEchoesSkipsCacheLoadWhenNoPendingEvents) fails: suppress held pendingMu while evaluating keep passes (0.01s) darwin/arm64, Go 1.26
QA-08 (TestTargetedScanRecordsRetryPathsOnTransientReadFailure) fails: RetryPaths = [], want "retry.txt" passes (0.01s) darwin/arm64, Go 1.26
QA-08 (TestWorkerRequeuesRetryPathsFromTargetedScan) fails: timed out waiting for targeted retry uploaded the file; cycles=2 passes (0.12s) darwin/arm64, Go 1.26
control (TestLocalWatchNotExist) passes passes darwin/arm64, Go 1.26
  • Reporter-equivalent environment check (darwin/arm64, 66,486-file workspace with 26 .bdriveignore rules): single-pass Filter.Skip over 66,492 paths dropped from 97.12s to 0.33s (and 0 calls on converged cache entries); live daemon propagation completed in 2.08s (existing-edit), 3.41s (new-file), 3.76s (new-directory), 3.97s (atomic-replace), and 3.88s (delete).

Risks and upgrade impact

  • On upgrade, running daemons automatically hot-swap the next time a bdrive command or agent hook runs. On-disk journal and state cache formats are unchanged.

Not run / not changed / not added

  • Not run: Windows internal/autostart runtime tests (compile-only on darwin/linux).
  • Not changed: fsnotify still uses kqueue on macOS for non-ignored directories; switching to a cgo FSEvents backend was rejected to preserve pure-Go builds (CGO_ENABLED=0).

@isac322 isac322 self-assigned this Nov 4, 2026
@isac322
isac322 requested a review from runbear-bot November 4, 2026 18:07

@runbear-bot runbear-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bobb's bot(Totoro) Review

Conclusion: not-ready

Blocker

  • internal/syncer/syncer.go:1155의 새 RetryPaths 기록은 walkFolderPaths 콜백 내부의 d.Info/Readlink/blob 읽기 실패만 다룹니다. 하지만 internal/syncer/walk.go:90-92는 filepath.WalkDir의 디렉터리 순회 오류를 return nil로 삼켜 콜백을 호출하지 않습니다. ScanEvent{Directory:true}는 같은 사이클에 deleteScopes를 설정하므로, 일시적인 디렉터리 열거 실패 시 해당 디렉터리의 캐시 자식들이 unseen으로 남아 삭제 op로 기록될 수 있고 peer로 전파됩니다. 디렉터리 순회 실패를 retry 대상으로 보존하고 삭제 pass를 막아야 합니다. 이 finding은 변경된 retry 경로에 inline으로 남겼습니다.

Verification

  • git diff --check: passed
  • 부모 source probe: passed — traversal error swallow, directory delete scope, callback-only retry recording을 현재 소스에서 확인
  • Go test/gofmt: not run (go toolchain unavailable; 0 / 0 / 0)
  • 독립 OMX draft도 같은 P1 blocker를 확인했습니다.

Reviewed head: 93ebca8b094d4cc897d0c77fb47aac9fa4ecd244.

Comment thread internal/syncer/syncer.go
seen := make(map[string]bool, len(cache))
retrySeen := map[string]bool{}
var retryPaths []string
recordRetry := func(rel string) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

디렉터리 순회 오류는 이 callback에 도달하지 않아 recordRetry가 호출되지 않습니다. ScanEvent{Directory:true}가 만든 deleteScopes와 결합되면 일시적인 WalkDir 실패가 캐시 자식들의 삭제 op로 오인될 수 있습니다. 순회 오류를 retry로 전파하고 해당 범위의 삭제 pass를 막아야 합니다.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in 52b18dc. Non-ENOENT WalkDir errors now report through walkFolderPathsWithErr to scan, which records the failed path in RetryPaths and blocks the delete pass for that path and its cached descendants (or skips the delete pass altogether when the mount root itself cannot be read). When the daemon re-queues RetryPaths, it sets ScanEvent.Directory from os.Stat so recovered directories are re-walked as directories. Verified with go test -race -v ./internal/syncer -run TestTargetedScanRecordsRetryPathsOnTransientReadFailure and go test -race -v ./internal/daemon -run TestWorkerRequeuesRetryPathsFromTargetedScan.

@isac322
isac322 requested a review from runbear-bot November 4, 2026 18:49

@runbear-bot runbear-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bobb's bot(Totoro) Review

Conclusion: ready-with-notes

  • 기존 디렉터리 순회 오류 finding은 현재 소스에서 오류 범위의 삭제 방지와 retry 경로가 추가된 것을 확인했습니다. 런타임 재현은 하지 못했습니다.
  • 참고: ignore 규칙을 추가할 때 이미 등록된 watcher는 즉시 제거되지 않아 해당 트리의 이벤트가 계속 발생할 수 있습니다. escaped / 규칙의 anchoring 동작도 기존과 달라 보이므로 호환성 테스트를 권합니다. 둘 다 실행 재현은 못 했습니다.
  • git diff --check: 통과. Go 도구가 없어 syncer/daemon 테스트는 실행 불가 (0 / 0 / 0); PR 작성자의 테스트 결과는 별도 보고이며 독립 검증하지 않았습니다.

Reviewed head: 52b18dcc8ed4b26007f6ff148c56166674de4d8a.

@isac322
isac322 merged commit f18f3fd into main Nov 4, 2026
8 of 9 checks passed
@isac322
isac322 deleted the isac322/fix-local-change-sync branch November 4, 2026 19:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants