Skip to content

Commit 00394cb

Browse files
committed
fix(mountsync): prune nested runtime trees before descent
1 parent 519a461 commit 00394cb

2 files changed

Lines changed: 237 additions & 61 deletions

File tree

internal/mountsync/syncer.go

Lines changed: 143 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,11 +1127,12 @@ type mountState struct {
11271127
// are additive/omitempty: legacy state files load with zero values
11281128
// (BootstrapComplete=false), which self-heals by forcing a full
11291129
// reconcile on the next cycle.
1130-
BootstrapComplete bool `json:"bootstrapComplete,omitempty"`
1131-
BootstrapCursor string `json:"bootstrapCursor,omitempty"`
1132-
BootstrapFilesSynced int `json:"bootstrapFilesSynced,omitempty"`
1133-
BootstrapFilesTotal int `json:"bootstrapFilesTotal,omitempty"`
1134-
BootstrapStartedAt string `json:"bootstrapStartedAt,omitempty"`
1130+
BootstrapComplete bool `json:"bootstrapComplete,omitempty"`
1131+
BootstrapDirectories []string `json:"bootstrapDirectories,omitempty"`
1132+
BootstrapCursor string `json:"bootstrapCursor,omitempty"`
1133+
BootstrapFilesSynced int `json:"bootstrapFilesSynced,omitempty"`
1134+
BootstrapFilesTotal int `json:"bootstrapFilesTotal,omitempty"`
1135+
BootstrapStartedAt string `json:"bootstrapStartedAt,omitempty"`
11351136
// QuarantinedPaths holds remote paths that cannot be materialized locally
11361137
// due to file/directory name collisions. Persisted across cycle restarts
11371138
// so the daemon does not re-fetch these paths from the cloud until the
@@ -4031,43 +4032,79 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s
40314032
metrics := fullTreeTraversalMetrics{startedAt: time.Now()}
40324033
defer func() {
40334034
s.logf(
4034-
"mount full-tree traversal summary remote_root=%q list_calls=%d entries_seen=%d files_seen=%d directories_seen=%d bytes_seen=%d runtime_entries_seen=%d traversal_complete=%t duration_ms=%d",
4035+
"mount full-tree traversal summary remote_root=%q list_calls=%d entries_seen=%d files_seen=%d directories_seen=%d bytes_seen=%d runtime_entries_seen=%d runtime_subtrees_pruned=%d traversal_complete=%t traversal_failed=%t duration_ms=%d",
40354036
s.remoteRoot,
40364037
metrics.listCalls,
40374038
metrics.entriesSeen,
40384039
metrics.filesSeen,
40394040
metrics.directoriesSeen,
40404041
metrics.bytesSeen,
40414042
metrics.runtimeEntriesSeen,
4043+
metrics.runtimeSubtreesPruned,
40424044
metrics.traversalComplete,
4045+
returnErr != nil,
40434046
time.Since(metrics.startedAt).Milliseconds(),
40444047
)
40454048
}()
40464049
remotePaths := map[string]struct{}{}
4047-
// Resumable bootstrap: if a prior bootstrap was interrupted mid-tree,
4048-
// pick traversal back up from the persisted cursor rather than
4049-
// re-reading everything. startedFromEmpty tracks whether this process
4050-
// traversed the WHOLE tree (empty start -> NextCursor==nil): only then
4051-
// is the snapshot delete pass authoritative. Resuming from a persisted
4052-
// cursor means we did not observe the full remote set this cycle, so
4053-
// the delete pass is skipped (next full cycle does the authoritative
4054-
// delete) — preserving the #164/#165 mount-root-clobber invariants.
4050+
// Traverse one directory level at a time. A deep ListTree request makes the
4051+
// server enumerate every descendant before the client can reject reserved
4052+
// runtime paths. The shallow queue sees a nested .relay directory first and
4053+
// can prune it without ever requesting its state/outbox descendants.
4054+
directories := []string{s.remoteRoot}
40554055
cursor := ""
4056-
if !s.state.BootstrapComplete && strings.TrimSpace(s.state.BootstrapCursor) != "" {
4057-
cursor = s.state.BootstrapCursor
4058-
s.logf("resuming bootstrap full-tree pull from persisted cursor (%d files already synced)", s.state.BootstrapFilesSynced)
4056+
startedFromEmpty := true
4057+
if !s.state.BootstrapComplete && len(s.state.BootstrapDirectories) > 0 {
4058+
resumedDirectories := make([]string, 0, len(s.state.BootstrapDirectories))
4059+
for _, directory := range s.state.BootstrapDirectories {
4060+
directory = normalizeRemotePath(directory)
4061+
if !isUnderRemoteRoot(s.remoteRoot, directory) || isMountRuntimeRemotePath(directory) {
4062+
continue
4063+
}
4064+
resumedDirectories = append(resumedDirectories, directory)
4065+
}
4066+
if len(resumedDirectories) > 0 {
4067+
directories = resumedDirectories
4068+
cursor = strings.TrimSpace(s.state.BootstrapCursor)
4069+
startedFromEmpty = false
4070+
s.logf("resuming bootstrap shallow-tree pull at %s from persisted cursor (%d directories pending, %d files already synced)", directories[0], len(directories), s.state.BootstrapFilesSynced)
4071+
}
4072+
} else if !s.state.BootstrapComplete && strings.TrimSpace(s.state.BootstrapCursor) != "" {
4073+
// v0.10.28 and older persisted a cursor into one depth=200 listing.
4074+
// That cursor is invalid for a depth=1 directory page, so restart from
4075+
// the root. The local-hash fast path avoids re-reading unchanged files.
4076+
s.logf("discarding legacy deep-tree bootstrap cursor and restarting shallow traversal from %s", s.remoteRoot)
4077+
s.state.BootstrapCursor = ""
4078+
}
4079+
queuedDirectories := make(map[string]struct{}, len(directories))
4080+
for _, directory := range directories {
4081+
queuedDirectories[directory] = struct{}{}
40594082
}
4060-
startedFromEmpty := cursor == ""
40614083
if s.state.BootstrapStartedAt == "" {
40624084
s.state.BootstrapStartedAt = time.Now().UTC().Format(time.RFC3339Nano)
40634085
}
4086+
persistTraversal := func(filesThisPage int) error {
4087+
if s.state.BootstrapComplete {
4088+
return nil
4089+
}
4090+
s.state.BootstrapDirectories = append([]string(nil), directories...)
4091+
s.state.BootstrapCursor = cursor
4092+
s.state.BootstrapFilesSynced += filesThisPage
4093+
prog.touch()
4094+
if err := s.saveState(); err != nil {
4095+
return err
4096+
}
4097+
prog.touch()
4098+
return nil
4099+
}
40644100
maxObservedRevision := ""
40654101
var transientBootstrapAbort bool
4066-
for {
4102+
for len(directories) > 0 {
4103+
currentDirectory := directories[0]
40674104
var page TreeResponse
40684105
var err error
40694106
s.runFullPullIO(func() {
4070-
page, err = s.client.ListTree(ctx, s.workspace, s.remoteRoot, 200, cursor)
4107+
page, err = s.client.ListTree(ctx, s.workspace, currentDirectory, 1, cursor)
40714108
})
40724109
metrics.listCalls++
40734110
if err != nil {
@@ -4079,6 +4116,7 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s
40794116
filesThisPage := 0
40804117
readJobs := make([]bootstrapReadJob, 0, len(page.Entries))
40814118
for _, entry := range page.Entries {
4119+
remotePath := normalizeRemotePath(entry.Path)
40824120
metrics.entriesSeen++
40834121
switch entry.Type {
40844122
case "file":
@@ -4089,26 +4127,35 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s
40894127
case "dir":
40904128
metrics.directoriesSeen++
40914129
}
4092-
if isMountRuntimeRemotePath(entry.Path) {
4130+
if isMountRuntimeRemotePath(remotePath) {
40934131
metrics.runtimeEntriesSeen++
40944132
}
4095-
if entry.Type != "file" {
4096-
continue
4097-
}
4098-
if revisionAdvances(maxObservedRevision, entry.Revision) {
4099-
maxObservedRevision = entry.Revision
4100-
}
4101-
remotePath := normalizeRemotePath(entry.Path)
41024133
if !isUnderRemoteRoot(s.remoteRoot, remotePath) {
41034134
continue
41044135
}
41054136
if s.skipMountRuntimeRemotePath(remotePath, conflicted) {
4137+
if entry.Type == "dir" {
4138+
metrics.runtimeSubtreesPruned++
4139+
}
41064140
continue
41074141
}
41084142
// Contract: lazy GitHub repos do not eagerly hydrate per-repo content at startup.
41094143
if s.lazyRepos && isUnderLazyGithubRepoSubtree(s.remoteRoot, remotePath) {
41104144
continue
41114145
}
4146+
if entry.Type == "dir" {
4147+
if _, exists := queuedDirectories[remotePath]; !exists {
4148+
queuedDirectories[remotePath] = struct{}{}
4149+
directories = append(directories, remotePath)
4150+
}
4151+
continue
4152+
}
4153+
if entry.Type != "file" {
4154+
continue
4155+
}
4156+
if revisionAdvances(maxObservedRevision, entry.Revision) {
4157+
maxObservedRevision = entry.Revision
4158+
}
41124159
if tracked, ok := s.state.Files[remotePath]; ok && tracked.Denied {
41134160
continue
41144161
}
@@ -4203,31 +4250,30 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s
42034250
// cursor so the same page (including the failing path) is retried next
42044251
// cycle. Save state up to the last successfully-committed cursor.
42054252
if transientBootstrapAbort {
4206-
if !s.state.BootstrapComplete {
4207-
s.state.BootstrapFilesSynced += filesThisPage
4208-
if err := s.saveState(); err != nil {
4209-
return err
4210-
}
4253+
if err := persistTraversal(filesThisPage); err != nil {
4254+
return err
42114255
}
42124256
break
42134257
}
4214-
if page.NextCursor == nil || *page.NextCursor == "" {
4215-
metrics.traversalComplete = true
4216-
break
4258+
if page.NextCursor != nil && *page.NextCursor != "" {
4259+
cursor = *page.NextCursor
4260+
if err := persistTraversal(filesThisPage); err != nil {
4261+
return err
4262+
}
4263+
continue
42174264
}
4218-
cursor = *page.NextCursor
4219-
// Persist the resume point + progress so an interrupted bootstrap
4220-
// (timeout, crash, watchdog cancel) picks up here next cycle
4221-
// instead of restarting the whole tree.
4222-
if !s.state.BootstrapComplete {
4223-
s.state.BootstrapCursor = cursor
4224-
s.state.BootstrapFilesSynced += filesThisPage
4225-
prog.touch()
4226-
if err := s.saveState(); err != nil {
4265+
// This directory is exhausted. Persist the remaining queue before
4266+
// requesting its next member so cancellation resumes at a directory
4267+
// boundary instead of restarting the root.
4268+
directories = directories[1:]
4269+
cursor = ""
4270+
if len(directories) > 0 {
4271+
if err := persistTraversal(filesThisPage); err != nil {
42274272
return err
42284273
}
4229-
prog.touch()
4274+
continue
42304275
}
4276+
metrics.traversalComplete = true
42314277
}
42324278

42334279
// A transient read error stopped the page scan early. Bootstrap is not
@@ -4240,8 +4286,8 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s
42404286

42414287
// Resumed/partial traversal safety: only run the authoritative
42424288
// snapshot delete pass when this process traversed the ENTIRE tree
4243-
// (started from an empty cursor and reached NextCursor==nil). If the
4244-
// traversal began from a persisted resume cursor, remotePaths only
4289+
// (started from the root with an empty directory queue). If the
4290+
// traversal began from a persisted directory/cursor boundary, remotePaths only
42454291
// covers the tail of the tree, so deleting "everything not in
42464292
// remotePaths" would wipe the already-mirrored prefix — exactly the
42474293
// #164/#165 clobber failure mode. Skip the delete pass this cycle;
@@ -4275,14 +4321,15 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s
42754321
}
42764322

42774323
type fullTreeTraversalMetrics struct {
4278-
startedAt time.Time
4279-
listCalls int
4280-
entriesSeen int
4281-
filesSeen int
4282-
directoriesSeen int
4283-
bytesSeen int64
4284-
runtimeEntriesSeen int
4285-
traversalComplete bool
4324+
startedAt time.Time
4325+
listCalls int
4326+
entriesSeen int
4327+
filesSeen int
4328+
directoriesSeen int
4329+
bytesSeen int64
4330+
runtimeEntriesSeen int
4331+
runtimeSubtreesPruned int
4332+
traversalComplete bool
42864333
}
42874334

42884335
type bootstrapReadJob struct {
@@ -4409,6 +4456,7 @@ func bootstrapReadWorkers() int {
44094456
// gate (Step 5) cannot be satisfied by a partial pull.
44104457
func (s *Syncer) markBootstrapComplete() {
44114458
s.state.BootstrapComplete = true
4459+
s.state.BootstrapDirectories = nil
44124460
s.state.BootstrapCursor = ""
44134461
s.state.BootstrapStartedAt = ""
44144462
s.state.BootstrapFilesSynced = 0
@@ -5496,19 +5544,52 @@ func (s *Syncer) skipMountRuntimeRemotePath(remotePath string, conflicted map[st
54965544
return false
54975545
}
54985546
s.logf("skipping mount runtime path surfaced as workspace content: %s", remotePath)
5499-
if err := s.applyRemoteDelete(remotePath, conflicted); err != nil {
5500-
s.logf("failed to clean local mount runtime path %s: %v", remotePath, err)
5547+
prefix := strings.TrimSuffix(remotePath, "/") + "/"
5548+
for trackedPath := range s.state.Files {
5549+
if trackedPath != remotePath && !strings.HasPrefix(trackedPath, prefix) {
5550+
continue
5551+
}
5552+
delete(s.state.Files, trackedPath)
5553+
s.clearIncrementalReadNotReady(trackedPath)
5554+
if conflicted != nil {
5555+
delete(conflicted, trackedPath)
5556+
}
5557+
}
5558+
for notReadyPath := range s.state.IncrementalReadNotReadySince {
5559+
if notReadyPath == remotePath || strings.HasPrefix(notReadyPath, prefix) {
5560+
s.clearIncrementalReadNotReady(notReadyPath)
5561+
}
55015562
}
55025563
if localPath, err := s.remoteToLocalPath(remotePath); err == nil {
5503-
if removeErr := os.Remove(localPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
5504-
s.logf("failed to remove local mount runtime file %s (%s): %v", remotePath, localPath, removeErr)
5564+
if s.isActiveMountRuntimeLocalPath(localPath) {
5565+
// A leaked remote /.relay entry maps onto this daemon's own public
5566+
// runtime directory. Reject the remote entry, but never delete the
5567+
// live local state/outbox while doing so.
5568+
} else if err := s.assertNotMountRoot(localPath); err != nil {
5569+
s.logf("failed to clean local mount runtime path %s: %v", remotePath, err)
5570+
} else if removeErr := os.RemoveAll(localPath); removeErr != nil {
5571+
s.logf("failed to remove local mount runtime subtree %s (%s): %v", remotePath, localPath, removeErr)
55055572
}
55065573
}
5507-
delete(s.state.Files, remotePath)
55085574
s.clearIncrementalReadNotReady(remotePath)
55095575
return true
55105576
}
55115577

5578+
func (s *Syncer) isActiveMountRuntimeLocalPath(localPath string) bool {
5579+
localPath = filepath.Clean(localPath)
5580+
publicRuntimeRoot := filepath.Join(filepath.Clean(s.localRoot), ".relay")
5581+
if relative, err := filepath.Rel(publicRuntimeRoot, localPath); err == nil &&
5582+
(relative == "." || (relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)))) {
5583+
return true
5584+
}
5585+
stateFile := filepath.Clean(s.stateFile)
5586+
if localPath == stateFile {
5587+
return true
5588+
}
5589+
return filepath.Dir(localPath) == filepath.Dir(stateFile) &&
5590+
strings.HasPrefix(filepath.Base(localPath), "."+filepath.Base(stateFile)+".tmp-")
5591+
}
5592+
55125593
func (s *Syncer) applyLocalPermissions(localPath string, canWrite bool) error {
55135594
if canWrite {
55145595
return os.Chmod(localPath, 0o644)
@@ -6202,6 +6283,7 @@ func (s *Syncer) loadState() error {
62026283
if s.state.BootstrapComplete && s.state.SyncMode == "write-only" && !s.writeOnly {
62036284
s.logf("syncMode transition write-only->mirror detected; resetting BootstrapComplete to force a full bootstrap pull (backfills records missed while write-only)")
62046285
s.state.BootstrapComplete = false
6286+
s.state.BootstrapDirectories = nil
62056287
s.state.BootstrapCursor = ""
62066288
s.state.BootstrapStartedAt = ""
62076289
s.state.BootstrapFilesSynced = 0

0 commit comments

Comments
 (0)