package state import ( "fmt" "os" "path/filepath" "sync" "testing" "time" ) // Every journal update used to be an unlocked open-modify-save. Two intake runs // on one root both read the journal, each filed its own album, and the second // save wrote back a snapshot taken before the first, so the first album's record // vanished while the album itself stayed on disk -- invisible to verify and audit, // and free to be filed a second time. func TestConcurrentUpdatesKeepEveryRecord(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") const n = 32 var wg sync.WaitGroup for i := 0; i < n; i++ { wg.Add(1) go func(i int) { defer wg.Done() src := fmt.Sprintf("/staging/album-%02d", i) err := Update(path, func(j *Journal) (bool, error) { j.Put(Record{Source: src, Phase: PhaseArchived, Album: src}) return true, nil }) if err != nil { t.Errorf("update %d: %v", i, err) } }(i) } wg.Wait() j, err := Open(path) if err != nil { t.Fatal(err) } if len(j.Albums) != n { t.Errorf("the journal holds %d records after %d concurrent updates", len(j.Albums), n) } for i := 0; i < n; i++ { src := fmt.Sprintf("/staging/album-%02d", i) if _, ok := j.Get(src); !ok { t.Errorf("the record for %s was lost", src) } } } func TestUpdateReleasesTheLock(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") if err := Update(path, func(j *Journal) (bool, error) { return true, nil }); err != nil { t.Fatal(err) } if _, err := os.Stat(path + ".lock"); !os.IsNotExist(err) { t.Error("the lock file was left behind") } // So a second update is not blocked by the first. if err := Update(path, func(j *Journal) (bool, error) { return true, nil }); err != nil { t.Fatalf("a later update could not take the lock: %v", err) } } // A lock that is never released -- a run killed while holding it -- must not stop // every later run for ever. func TestAStaleLockIsBroken(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path+".lock", []byte("99999\n"), 0o600); err != nil { t.Fatal(err) } old := time.Now().Add(-2 * lockStale) if err := os.Chtimes(path+".lock", old, old); err != nil { t.Fatal(err) } done := make(chan error, 1) go func() { done <- Update(path, func(j *Journal) (bool, error) { j.Put(Record{Source: "/staging/x", Phase: PhaseArchived}) return true, nil }) }() select { case err := <-done: if err != nil { t.Fatalf("a stale lock stopped the update: %v", err) } case <-time.After(5 * time.Second): t.Fatal("a stale lock was waited on instead of being broken") } } func TestSaveWritesAtomically(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "state.json") j, err := Open(path) if err != nil { t.Fatal(err) } j.Put(Record{Source: "/staging/x", Phase: PhaseArchived, Album: "An Album"}) if err := j.Save(); err != nil { t.Fatal(err) } // No temporary files left behind, and a second save does not collide with a // fixed name. if err := j.Save(); err != nil { t.Fatal(err) } entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } for _, e := range entries { if e.Name() != "state.json" { t.Errorf("unexpected leftover %s", e.Name()) } } again, err := Open(path) if err != nil { t.Fatal(err) } if rec, ok := again.Get("/staging/x"); !ok || rec.Album != "An Album" { t.Errorf("the saved record did not come back: %+v", rec) } }