git/intake/main
internal/state/state.go
201 lines5 KB last change raw · history
package state

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"
)

type Phase string

const (
	PhaseDiscovered Phase = "discovered"
	PhaseReviewed   Phase = "reviewed"
	PhaseArchived   Phase = "archived"
	PhaseFailed     Phase = "failed"
	// PhaseNeedsAttention is an album an unattended run left alone because
	// something about it needs a person: an unknown artist, a name that cannot
	// be normalised, a file that is not lossless. It is not a failure -- nothing
	// was written -- and the album is still sitting in staging.
	PhaseNeedsAttention Phase = "needs_attention"
)

type Record struct {
	Source      string `json:"source"`
	AlbumArtist string `json:"album_artist,omitempty"`
	Album       string `json:"album,omitempty"`
	Phase       Phase  `json:"phase"`
	ArchiveDir  string `json:"archive_dir,omitempty"`
	LibraryDir  string `json:"library_dir,omitempty"`
	Tracks      int    `json:"tracks"`
	// Adopted marks a record built from an archive that was already on disk
	// rather than from a run that filed the album.
	Adopted      bool      `json:"adopted,omitempty"`
	Placeholder  bool      `json:"placeholder_cover"`
	LibraryBuilt bool      `json:"library_built"`
	Issues       []string  `json:"issues,omitempty"`
	UpdatedAt    time.Time `json:"updated_at"`
}

type Journal struct {
	Version int               `json:"version"`
	Albums  map[string]Record `json:"albums"`
	path    string
}

func Open(path string) (*Journal, error) {
	j := &Journal{Version: 1, Albums: map[string]Record{}, path: path}
	data, err := os.ReadFile(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return j, nil
		}
		return nil, err
	}
	if len(data) == 0 {
		return j, nil
	}
	if err := json.Unmarshal(data, j); err != nil {
		return nil, err
	}
	if j.Albums == nil {
		j.Albums = map[string]Record{}
	}
	j.path = path
	return j, nil
}

func (j *Journal) Path() string { return j.path }

func (j *Journal) Get(source string) (Record, bool) {
	r, ok := j.Albums[source]
	return r, ok
}

func (j *Journal) Put(r Record) {
	r.UpdatedAt = time.Now().UTC()
	j.Albums[r.Source] = r
}

func (j *Journal) Delete(source string) {
	delete(j.Albums, source)
}

func (j *Journal) Save() error {
	if j.path == "" {
		return nil
	}
	if err := os.MkdirAll(filepath.Dir(j.path), 0o755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(j, "", "  ")
	if err != nil {
		return err
	}
	// A temp name of its own, so that two writers cannot rename each other's
	// half-written file into place. The fixed ".tmp" name meant the loser of a
	// race got "no such file or directory" from its own rename and lost the
	// record it was trying to write.
	tmp, err := os.CreateTemp(filepath.Dir(j.path), filepath.Base(j.path)+".tmp-*")
	if err != nil {
		return err
	}
	name := tmp.Name()
	if _, err := tmp.Write(data); err != nil {
		tmp.Close()
		os.Remove(name)
		return err
	}
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		os.Remove(name)
		return err
	}
	if err := tmp.Close(); err != nil {
		os.Remove(name)
		return err
	}
	if err := os.Rename(name, j.path); err != nil {
		os.Remove(name)
		return err
	}
	return nil
}

// Update runs fn against the journal with the journal locked, and saves the
// result when fn asks for that. The lock is what makes a read, a change and a
// write one indivisible step.
//
// Every update used to be an unlocked open-modify-save. Two intake processes on
// one root both read the journal, each filed its own album, and the second save
// wrote back a snapshot taken before the first one, so the first album's record
// vanished while the album itself stayed on disk. Nothing downstream could tell:
// verify and audit work from the journal, so the album stopped existing as far as
// intake was concerned, and the next resume filed it a second time.
func Update(path string, fn func(*Journal) (bool, error)) error {
	if path == "" {
		return nil
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	unlock, err := lockJournal(path)
	if err != nil {
		return err
	}
	defer unlock()

	j, err := Open(path)
	if err != nil {
		return err
	}
	save, err := fn(j)
	if err != nil {
		return err
	}
	if !save {
		return nil
	}
	return j.Save()
}

const (
	lockPoll  = 25 * time.Millisecond
	lockWait  = 15 * time.Second
	lockStale = 5 * time.Minute
)

// lockJournal takes a cooperative lock beside the journal. It is a lock file
// created with O_EXCL rather than flock, because flock is not available
// everywhere intake builds and the window it protects is a few milliseconds of
// file writing rather than a long-running operation.
//
// A lock older than lockStale is broken rather than waited on for ever: the
// alternative is that a run killed while holding one stops every later run.
func lockJournal(path string) (func(), error) {
	lock := path + ".lock"
	deadline := time.Now().Add(lockWait)
	for {
		f, err := os.OpenFile(lock, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
		if err == nil {
			fmt.Fprintf(f, "%d\n", os.Getpid())
			f.Close()
			return func() { os.Remove(lock) }, nil
		}
		if !os.IsExist(err) {
			return nil, err
		}
		if fi, statErr := os.Stat(lock); statErr == nil && time.Since(fi.ModTime()) > lockStale {
			os.Remove(lock)
			continue
		}
		if time.Now().After(deadline) {
			return nil, fmt.Errorf("the journal at %s is locked by another intake run (%s); wait for it to finish, or delete that file if no run is active", path, lock)
		}
		time.Sleep(lockPoll)
	}
}