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)
}
}