ananke/internal/state/store.go

129 lines
2.6 KiB
Go

package state
import (
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
type RunRecord struct {
ID string `json:"id"`
Action string `json:"action"`
Reason string `json:"reason,omitempty"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
DurationSeconds int `json:"duration_seconds"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
type Store struct {
path string
mu sync.Mutex
}
func New(path string) *Store {
return &Store{path: path}
}
func EnsureDir(dir string) error {
if dir == "" {
return fmt.Errorf("state dir must not be empty")
}
return os.MkdirAll(dir, 0o750)
}
func AcquireLock(path string) (func(), error) {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return nil, fmt.Errorf("acquire lock %s: %w", path, err)
}
_, _ = f.WriteString(fmt.Sprintf("pid=%d started=%s\n", os.Getpid(), time.Now().Format(time.RFC3339)))
_ = f.Close()
return func() {
_ = os.Remove(path)
}, nil
}
func (s *Store) Append(record RunRecord) error {
s.mu.Lock()
defer s.mu.Unlock()
records, err := s.loadUnlocked()
if err != nil {
return err
}
records = append(records, record)
if len(records) > 200 {
records = records[len(records)-200:]
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o750); err != nil {
return err
}
b, err := json.MarshalIndent(records, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, b, 0o640)
}
func (s *Store) Load() ([]RunRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.loadUnlocked()
}
func (s *Store) loadUnlocked() ([]RunRecord, error) {
b, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
if len(b) == 0 {
return nil, nil
}
var records []RunRecord
if err := json.Unmarshal(b, &records); err != nil {
return nil, err
}
return records, nil
}
func (s *Store) ShutdownP95(defaultSeconds int) int {
records, err := s.Load()
if err != nil {
return defaultSeconds
}
var d []int
for _, r := range records {
if r.Action == "shutdown" && r.Success && r.DurationSeconds > 0 {
d = append(d, r.DurationSeconds)
}
}
if len(d) == 0 {
return defaultSeconds
}
sort.Ints(d)
idx := int(math.Ceil(0.95*float64(len(d)))) - 1
if idx < 0 {
idx = 0
}
if idx >= len(d) {
idx = len(d) - 1
}
if d[idx] <= 0 {
return defaultSeconds
}
return d[idx]
}