git/intake/main
internal/state/state_test.go
134 lines3 KB last change raw · history
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)
	}
}