mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
fix: make daemon and state cache per-mount, not per-volume
One volume can be mounted at several folders (e.g. ./shared in multiple repos defaulting to the same volume name). Previously the daemon pidfile and materialization cache were keyed per volume, so mounts fought over a single daemon and shared one folder-state cache. Key both by a mount ID (hash of the absolute folder path); blobs, journals, and the lamport clock stay shared per volume. Content now propagates between co-mounted folders even offline. Also default remote polling 30s -> 10s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"lastScanned": 1781278954384,
|
||||
"projectRoot": "/Users/ssowonny/Workspace/runbear/sfs",
|
||||
"techStack": {
|
||||
"languages": [
|
||||
{
|
||||
"name": "Go",
|
||||
"version": null,
|
||||
"confidence": "high",
|
||||
"markers": [
|
||||
"go.mod"
|
||||
]
|
||||
}
|
||||
],
|
||||
"frameworks": [],
|
||||
"packageManager": "go",
|
||||
"runtime": null
|
||||
},
|
||||
"build": {
|
||||
"buildCommand": null,
|
||||
"testCommand": null,
|
||||
"lintCommand": null,
|
||||
"devCommand": null,
|
||||
"scripts": {}
|
||||
},
|
||||
"conventions": {
|
||||
"namingStyle": null,
|
||||
"importStyle": null,
|
||||
"testPattern": null,
|
||||
"fileOrganization": null
|
||||
},
|
||||
"structure": {
|
||||
"isMonorepo": false,
|
||||
"workspaces": [],
|
||||
"mainDirectories": [],
|
||||
"gitBranches": null
|
||||
},
|
||||
"customNotes": [],
|
||||
"directoryMap": {
|
||||
"cmd": {
|
||||
"path": "cmd",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1781278954347,
|
||||
"keyFiles": []
|
||||
},
|
||||
"internal": {
|
||||
"path": "internal",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1781278954360,
|
||||
"keyFiles": []
|
||||
},
|
||||
"packaging": {
|
||||
"path": "packaging",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1781278954360,
|
||||
"keyFiles": []
|
||||
},
|
||||
"internal/config": {
|
||||
"path": "internal/config",
|
||||
"purpose": "Configuration files",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1781278954367,
|
||||
"keyFiles": [
|
||||
"config.go"
|
||||
]
|
||||
}
|
||||
},
|
||||
"hotPaths": [
|
||||
{
|
||||
"path": "internal/daemon/daemon.go",
|
||||
"accessCount": 4,
|
||||
"lastAccessed": 1781279115286,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "internal/syncer/syncer.go",
|
||||
"accessCount": 3,
|
||||
"lastAccessed": 1781279091569,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "internal/config/config.go",
|
||||
"accessCount": 2,
|
||||
"lastAccessed": 1781279069157,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "cmd/sfs/cmds.go",
|
||||
"accessCount": 2,
|
||||
"lastAccessed": 1781279129227,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "internal/store/store.go",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1781279075626,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "cmd/sfs/mount.go",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1781279125661,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "internal/store/store_test.go",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1781279133287,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "internal/syncer/syncer_test.go",
|
||||
"accessCount": 1,
|
||||
"lastAccessed": 1781279139436,
|
||||
"type": "file"
|
||||
}
|
||||
],
|
||||
"userDirectives": []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"t":0,"agent":"system","event":"keyword_detected","keyword":"wiki"}
|
||||
@@ -0,0 +1 @@
|
||||
{"session_id":"d0d876ca-992f-4ad4-98d3-105e3371e069","transcript_path":"/Users/ssowonny/.claude/projects/-Users-ssowonny-Workspace-runbear-sfs/d0d876ca-992f-4ad4-98d3-105e3371e069.jsonl","cwd":"/Users/ssowonny/Workspace/runbear/sfs","effort":{"level":"xhigh"},"model":{"id":"claude-opus-4-7[1m]","display_name":"Opus 4.7 (1M context)"},"workspace":{"current_dir":"/Users/ssowonny/Workspace/runbear/sfs","project_dir":"/Users/ssowonny/Workspace/runbear/sfs","added_dirs":[]},"version":"2.1.141","output_style":{"name":"default"},"cost":{"total_cost_usd":0,"total_duration_ms":294,"total_api_duration_ms":0,"total_lines_added":0,"total_lines_removed":0},"context_window":{"total_input_tokens":0,"total_output_tokens":0,"context_window_size":1000000,"current_usage":null,"used_percentage":null,"remaining_percentage":null},"exceeds_200k_tokens":false,"fast_mode":false,"thinking":{"enabled":true}}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"lastSentAt": "2026-06-12T15:37:47.380Z"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input_preview": "{\"command\":\"gcloud storage buckets create gs://runbear-wiki --project=runbear-local --location=us-west1 --uniform-bucket-level-access 2>&1 && gcloud config set project runbear-local 2>&1 | tail -1\",\"d...",
|
||||
"error": "Exit code 1\nCreating gs://runbear-wiki/...\nERROR: (gcloud.storage.buckets.create) HTTPError 403: snow@runbear.io does not have storage.buckets.create access to the Google Cloud project. Permission 'storage.buckets.create' denied on resource '//storage.googleapis.com/projects/_/buckets/runbear-wiki' (or it may not exist). This command is authenticated as snow@runbear.io which is the active account specified by the [core/account] property.",
|
||||
"timestamp": "2026-06-12T15:32:54.007Z",
|
||||
"retry_count": 1
|
||||
}
|
||||
@@ -129,8 +129,9 @@ working folder ←materialize/scan→ local volume store ←push/pull→ obj
|
||||
Concurrent edits keep the last writer at the path; the loser is preserved
|
||||
as a conflict-copy file by the device that detects the overlap.
|
||||
- A per-mount **daemon** scans the folder every few seconds (cheap
|
||||
size+mtime check) and exchanges with the remote every ~30s — or
|
||||
immediately after local edits.
|
||||
size+mtime check) and exchanges with the remote every ~10s — or
|
||||
immediately after local edits. Tune with --scan-interval and
|
||||
--remote-interval on `sfs mnt`.
|
||||
|
||||
### What sfs does not sync
|
||||
|
||||
|
||||
+3
-3
@@ -86,7 +86,7 @@ func statusCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pid, ok := daemon.Running(vdir); ok {
|
||||
if pid, ok := daemon.Running(vdir, config.MountID(folder)); ok {
|
||||
fmt.Printf(" daemon: running (pid %d)\n", pid)
|
||||
} else {
|
||||
fmt.Printf(" daemon: stopped\n")
|
||||
@@ -95,7 +95,7 @@ func statusCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cache, err := sess.Store.LoadCache()
|
||||
cache, err := sess.Store.LoadCache(config.MountID(folder))
|
||||
if err == nil {
|
||||
var total int64
|
||||
for _, c := range cache {
|
||||
@@ -245,7 +245,7 @@ func daemonCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
run.Flags().DurationVar(&scanInterval, "scan-interval", 3*time.Second, "local scan interval")
|
||||
run.Flags().DurationVar(&remoteInterval, "remote-interval", 30*time.Second, "remote sync interval")
|
||||
run.Flags().DurationVar(&remoteInterval, "remote-interval", 10*time.Second, "remote sync interval")
|
||||
c.AddCommand(run)
|
||||
return c
|
||||
}
|
||||
|
||||
+2
-2
@@ -113,7 +113,7 @@ daemon keeps the folder in sync until "sfs umnt".`,
|
||||
c.Flags().StringVarP(&volume, "volume", "v", "", "volume name (default: folder basename)")
|
||||
c.Flags().BoolVarP(&foreground, "foreground", "f", false, "run the sync daemon in the foreground")
|
||||
c.Flags().DurationVar(&scanInterval, "scan-interval", 3*time.Second, "how often to scan the folder for local changes")
|
||||
c.Flags().DurationVar(&remoteInterval, "remote-interval", 30*time.Second, "how often to sync with the remote")
|
||||
c.Flags().DurationVar(&remoteInterval, "remote-interval", 10*time.Second, "how often to sync with the remote")
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ volume data under ~/.sfs/volumes is still kept).`,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stopped, err := daemon.Stop(vdir)
|
||||
stopped, err := daemon.Stop(vdir, config.MountID(folder))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
@@ -136,6 +137,15 @@ func SaveMounts(m map[string]MountInfo) error {
|
||||
return writeJSON(p, m)
|
||||
}
|
||||
|
||||
// MountID derives a stable identifier for a mounted folder. One volume can
|
||||
// be mounted at several folders (even on the same device); everything
|
||||
// folder-specific — the sync daemon and the materialization cache — is keyed
|
||||
// by this ID, while blobs and journals stay shared per volume.
|
||||
func MountID(folder string) string {
|
||||
sum := sha256.Sum256([]byte(folder))
|
||||
return hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
// VolumeDir returns (and creates parents for) the local store dir of a volume.
|
||||
func VolumeDir(volume string) (string, error) {
|
||||
home, err := Home()
|
||||
|
||||
+25
-16
@@ -26,12 +26,19 @@ import (
|
||||
"github.com/runbear-io/sfs/internal/syncer"
|
||||
)
|
||||
|
||||
func PidPath(volDir string) string { return filepath.Join(volDir, "daemon.pid") }
|
||||
func LogPath(volDir string) string { return filepath.Join(volDir, "daemon.log") }
|
||||
// Daemons are per mount, not per volume: one volume may be mounted at
|
||||
// several folders (each gets its own daemon), so pid/log files are keyed by
|
||||
// the mount ID.
|
||||
func PidPath(volDir, mountID string) string {
|
||||
return filepath.Join(volDir, "daemon-"+mountID+".pid")
|
||||
}
|
||||
func LogPath(volDir, mountID string) string {
|
||||
return filepath.Join(volDir, "daemon-"+mountID+".log")
|
||||
}
|
||||
|
||||
// Running reports the daemon pid for a volume if one is alive.
|
||||
func Running(volDir string) (int, bool) {
|
||||
data, err := os.ReadFile(PidPath(volDir))
|
||||
// Running reports the daemon pid for a mount if one is alive.
|
||||
func Running(volDir, mountID string) (int, bool) {
|
||||
data, err := os.ReadFile(PidPath(volDir, mountID))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
@@ -47,14 +54,15 @@ func Running(volDir string) (int, bool) {
|
||||
|
||||
// Start launches a detached daemon for the folder (no-op if already running).
|
||||
func Start(folder, volDir string, scanInterval, remoteInterval time.Duration) (int, error) {
|
||||
if pid, ok := Running(volDir); ok {
|
||||
mountID := config.MountID(folder)
|
||||
if pid, ok := Running(volDir, mountID); ok {
|
||||
return pid, nil
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
logf, err := os.OpenFile(LogPath(volDir), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
logf, err := os.OpenFile(LogPath(volDir, mountID), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -69,17 +77,17 @@ func Start(folder, volDir string, scanInterval, remoteInterval time.Duration) (i
|
||||
return 0, err
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
if err := os.WriteFile(PidPath(volDir), []byte(strconv.Itoa(pid)+"\n"), 0o644); err != nil {
|
||||
if err := os.WriteFile(PidPath(volDir, mountID), []byte(strconv.Itoa(pid)+"\n"), 0o644); err != nil {
|
||||
return pid, err
|
||||
}
|
||||
return pid, cmd.Process.Release()
|
||||
}
|
||||
|
||||
// Stop terminates the daemon for a volume and waits for it to exit.
|
||||
func Stop(volDir string) (bool, error) {
|
||||
pid, ok := Running(volDir)
|
||||
// Stop terminates the daemon for a mount and waits for it to exit.
|
||||
func Stop(volDir, mountID string) (bool, error) {
|
||||
pid, ok := Running(volDir, mountID)
|
||||
if !ok {
|
||||
os.Remove(PidPath(volDir))
|
||||
os.Remove(PidPath(volDir, mountID))
|
||||
return false, nil
|
||||
}
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
@@ -88,13 +96,13 @@ func Stop(volDir string) (bool, error) {
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if err := syscall.Kill(pid, 0); err != nil {
|
||||
os.Remove(PidPath(volDir))
|
||||
os.Remove(PidPath(volDir, mountID))
|
||||
return true, nil
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
syscall.Kill(pid, syscall.SIGKILL)
|
||||
os.Remove(PidPath(volDir))
|
||||
os.Remove(PidPath(volDir, mountID))
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -124,10 +132,11 @@ func Run(folder string, scanInterval, remoteInterval time.Duration) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(PidPath(volDir), []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
|
||||
mountID := config.MountID(folder)
|
||||
if err := os.WriteFile(PidPath(volDir, mountID), []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(PidPath(volDir))
|
||||
defer os.Remove(PidPath(volDir, mountID))
|
||||
|
||||
log.Printf("daemon started: folder=%s volume=%s remote=%q device=%s(%s) scan=%s sync=%s",
|
||||
folder, mi.Volume, mi.Remote, dev.Name, dev.ID, scanInterval, remoteInterval)
|
||||
|
||||
+10
-5
@@ -154,7 +154,8 @@ func (s *Store) AllOps() ([]journal.Op, error) {
|
||||
|
||||
// CachedFile records what sfs last wrote to / observed in the working folder
|
||||
// for a path. Size+MTimeNS make change detection cheap; Blob ties it back to
|
||||
// content.
|
||||
// content. The cache is per mount (one volume can be materialized into
|
||||
// several folders, each with its own stat fingerprints).
|
||||
type CachedFile struct {
|
||||
Blob string `json:"blob"`
|
||||
Size int64 `json:"size"`
|
||||
@@ -162,16 +163,20 @@ type CachedFile struct {
|
||||
MTimeNS int64 `json:"mtime_ns"`
|
||||
}
|
||||
|
||||
func (s *Store) LoadCache() (map[string]CachedFile, error) {
|
||||
func (s *Store) cachePath(mountID string) string {
|
||||
return filepath.Join(s.dir, "state-"+mountID+".json")
|
||||
}
|
||||
|
||||
func (s *Store) LoadCache(mountID string) (map[string]CachedFile, error) {
|
||||
out := map[string]CachedFile{}
|
||||
if err := readJSON(filepath.Join(s.dir, "state.json"), &out); err != nil {
|
||||
if err := readJSON(s.cachePath(mountID), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveCache(c map[string]CachedFile) error {
|
||||
return WriteJSONAtomic(filepath.Join(s.dir, "state.json"), c)
|
||||
func (s *Store) SaveCache(mountID string, c map[string]CachedFile) error {
|
||||
return WriteJSONAtomic(s.cachePath(mountID), c)
|
||||
}
|
||||
|
||||
// ---- sync state (sync.json) ----
|
||||
|
||||
@@ -61,13 +61,18 @@ func TestJournalAndState(t *testing.T) {
|
||||
}
|
||||
|
||||
cache := map[string]CachedFile{"f": {Blob: "b", Size: 1, MTimeNS: 42}}
|
||||
if err := s.SaveCache(cache); err != nil {
|
||||
if err := s.SaveCache("m1", cache); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.LoadCache()
|
||||
got, err := s.LoadCache("m1")
|
||||
if err != nil || got["f"].MTimeNS != 42 {
|
||||
t.Fatalf("cache roundtrip: %v %v", got, err)
|
||||
}
|
||||
// caches are isolated per mount
|
||||
other, err := s.LoadCache("m2")
|
||||
if err != nil || len(other) != 0 {
|
||||
t.Fatalf("mount caches must be isolated: %v %v", other, err)
|
||||
}
|
||||
|
||||
st := SyncState{Lamport: 7, PushedOps: 3}
|
||||
if err := s.SaveSync(st); err != nil {
|
||||
|
||||
@@ -32,11 +32,19 @@ import (
|
||||
// Session ties a working folder to its volume store and (optionally) remote.
|
||||
type Session struct {
|
||||
Folder string
|
||||
MountID string // distinguishes folders sharing one volume; see config.MountID
|
||||
Store *store.Store
|
||||
Device config.Device
|
||||
Backend remote.Backend // nil = work offline
|
||||
}
|
||||
|
||||
func (s *Session) mountID() string {
|
||||
if s.MountID != "" {
|
||||
return s.MountID
|
||||
}
|
||||
return config.MountID(s.Folder)
|
||||
}
|
||||
|
||||
// Result summarizes one sync cycle.
|
||||
type Result struct {
|
||||
LocalOps int // local changes committed to the journal
|
||||
@@ -68,7 +76,7 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) {
|
||||
defer unlock()
|
||||
|
||||
res := &Result{}
|
||||
cache, err := s.Store.LoadCache()
|
||||
cache, err := s.Store.LoadCache(s.mountID())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load state: %w", err)
|
||||
}
|
||||
@@ -147,7 +155,7 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.Store.SaveCache(cache); err != nil {
|
||||
if err := s.Store.SaveCache(s.mountID(), cache); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.Store.SaveSync(st); err != nil {
|
||||
|
||||
@@ -245,6 +245,38 @@ func TestOfflineThenReconnect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameVolumeMountedAtTwoFolders(t *testing.T) {
|
||||
// One device mounts the same volume at two folders (e.g. ./shared in two
|
||||
// repos). They share the store (blobs+journals) but have separate mount
|
||||
// caches, and content propagates between them even with no remote.
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "volume"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dev := config.Device{ID: "dev1", Name: "dev1", Author: "dev1@test"}
|
||||
m1 := &Session{Folder: t.TempDir(), MountID: "mount1", Store: st, Device: dev}
|
||||
m2 := &Session{Folder: t.TempDir(), MountID: "mount2", Store: st, Device: dev}
|
||||
|
||||
write(t, m1.Folder, "shared.md", "from folder one")
|
||||
cycle(t, m1)
|
||||
res := cycle(t, m2)
|
||||
if res.Materialized != 1 {
|
||||
t.Fatalf("folder two should materialize the file: %+v", res)
|
||||
}
|
||||
if read(t, m2.Folder, "shared.md") != "from folder one" {
|
||||
t.Fatal("content did not propagate between mounts")
|
||||
}
|
||||
|
||||
// edit in folder two propagates back
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
write(t, m2.Folder, "shared.md", "edited in folder two")
|
||||
cycle(t, m2)
|
||||
cycle(t, m1)
|
||||
if read(t, m1.Folder, "shared.md") != "edited in folder two" {
|
||||
t.Fatal("edit did not propagate back to folder one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutableBitPreserved(t *testing.T) {
|
||||
be := sharedRemote(t)
|
||||
a := newDevice(t, "deva", be)
|
||||
|
||||
Reference in New Issue
Block a user