package moves import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "sort" "time" "github.com/carlosframework/platform/internal/registry" ) // timerWire is registry.Timer with the registration on the wire: the // snapshot carries it so ReplaceTimers can preserve it (§9.4). type timerWire struct { Host, Name, Kind, Spec, Path string NextAt, LastAt int64 LastStatus string LastMs int64 Attempts int CreatedAt, DueAt int64 Registration string } func toWire(t registry.Timer) timerWire { return timerWire{t.Host, t.Name, t.Kind, t.Spec, t.Path, t.NextAt, t.LastAt, t.LastStatus, t.LastMs, t.Attempts, t.CreatedAt, t.DueAt, t.Registration} } func fromWire(w timerWire) registry.Timer { return registry.Timer{Host: w.Host, Name: w.Name, Kind: w.Kind, Spec: w.Spec, Path: w.Path, NextAt: w.NextAt, LastAt: w.LastAt, LastStatus: w.LastStatus, LastMs: w.LastMs, Attempts: w.Attempts, CreatedAt: w.CreatedAt, DueAt: w.DueAt, Registration: w.Registration} } // TimersSnapshot is the timers table for one host as carried (§6.2, §9.4). type TimersSnapshot struct { V int `json:"v"` Host string `json:"host"` Rows []registry.Timer `json:"-"` TakenAt time.Time `json:"taken_at"` } type timersWire struct { V int `json:"v"` Host string `json:"host"` Rows []timerWire `json:"rows"` TakenAt time.Time `json:"taken_at"` } func (s TimersSnapshot) Encode() ([]byte, error) { w := timersWire{V: timersVersion, Host: s.Host, TakenAt: s.TakenAt} for _, r := range s.Rows { w.Rows = append(w.Rows, toWire(r)) } return json.Marshal(w) } // timersVersion is the only snapshot schema this reader understands. const timersVersion = 1 // DecodeTimers reads a v1 snapshot. Any other version, a missing version // and a JSON null are refused before any row is returned: a newer schema // could carry state this reader would drop, and re-encoding would then // label the partial reading v1. func DecodeTimers(b []byte) (TimersSnapshot, error) { var w timersWire if err := json.Unmarshal(b, &w); err != nil { return TimersSnapshot{}, fmt.Errorf("moves: timers snapshot: %w", err) } if w.V != timersVersion { return TimersSnapshot{}, fmt.Errorf("moves: timers snapshot: unsupported version %d (want %d)", w.V, timersVersion) } s := TimersSnapshot{V: w.V, Host: w.Host, TakenAt: w.TakenAt} for _, r := range w.Rows { s.Rows = append(s.Rows, fromWire(r)) } return s, nil } // ActiveTimers drops disposable history: a once-timer that has fired and // has no next occurrence. Everything else is acknowledged state (INV-2). func ActiveTimers(rows []registry.Timer) []registry.Timer { var out []registry.Timer for _, t := range rows { if t.Kind == "once" && t.NextAt == 0 && t.LastStatus != "" { continue } out = append(out, t) } return out } // TimersDigest is the canonical digest a clean recovery compares (§9.4): // active rows, sorted by name then kind, every bookkeeping column. func TimersDigest(rows []registry.Timer) string { active := ActiveTimers(rows) sort.Slice(active, func(i, j int) bool { if active[i].Name != active[j].Name { return active[i].Name < active[j].Name } return active[i].Kind < active[j].Kind }) h := sha256.New() for _, t := range active { b, _ := json.Marshal(toWire(t)) h.Write(b) h.Write([]byte{'\n'}) } return "sha256:" + hex.EncodeToString(h.Sum(nil)) }