Implemented confi snapshots

This commit is contained in:
Martino Ferrari
2026-06-21 23:50:03 +02:00
parent 04d31a15c4
commit 113e5a0fe8
51 changed files with 4921 additions and 241 deletions
@@ -21,6 +21,12 @@ type SignalDef struct {
Visibility string `json:"visibility,omitempty"`
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
// Version and Tag implement git-style revisioning. Version is bumped on
// every UpdateSignal; superseded revisions are kept as backup files. Tag is
// an optional human label for a revision (e.g. "restored from v3").
Version int `json:"version,omitempty"`
Tag string `json:"tag,omitempty"`
}
// InputRef names one upstream signal used as input to the pipeline.
@@ -263,6 +263,9 @@ func (s *Synthetic) AddSignal(def SignalDef) error {
if def.Name == "" {
return errors.New("signal name must not be empty")
}
if def.Version < 1 {
def.Version = 1
}
rg, err := compileGraph(def)
if err != nil {
@@ -340,6 +343,16 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error {
s.mu.Unlock()
return datasource.ErrNotFound
}
// Preserve the superseded revision as a backup and bump the version.
oldDef := old.def
if oldDef.Version < 1 {
oldDef.Version = 1
}
if err := s.backupVersion(oldDef); err != nil {
s.mu.Unlock()
return fmt.Errorf("back up revision: %w", err)
}
def.Version = oldDef.Version + 1
if old.cancel != nil {
old.cancel()
}
+174
View File
@@ -0,0 +1,174 @@
package synthetic
import (
"encoding/json"
"fmt"
"hash/fnv"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode"
"github.com/uopi/uopi/internal/datasource"
)
// VersionMeta describes a single persisted revision of a synthetic signal,
// mirroring storage.VersionMeta so the frontend treats every versioned document
// type uniformly.
type VersionMeta struct {
Version int `json:"version"`
Name string `json:"name"`
Tag string `json:"tag,omitempty"`
Current bool `json:"current"`
SavedAt time.Time `json:"savedAt"`
}
func (s *Synthetic) versionsDir() string {
return filepath.Join(s.storePath, "synthetic_versions")
}
// slugForFile maps an arbitrary signal name to a filesystem-safe stem. A short
// hash of the full name is appended so distinct names that sanitise to the same
// stem (e.g. "A:B" and "A_B") never share backup files.
func slugForFile(name string) string {
var b strings.Builder
for _, r := range name {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
h := fnv.New32a()
_, _ = h.Write([]byte(name))
return fmt.Sprintf("%s-%08x", b.String(), h.Sum32())
}
func (s *Synthetic) versionPath(name string, version int) string {
return filepath.Join(s.versionsDir(), fmt.Sprintf("%s.v%d.json", slugForFile(name), version))
}
// backupVersion writes a single signal revision to the versions directory.
func (s *Synthetic) backupVersion(def SignalDef) error {
if err := os.MkdirAll(s.versionsDir(), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(def, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.versionPath(def.Name, def.Version), data, 0o644)
}
// Versions returns metadata for every persisted revision of the named signal,
// newest first. The live revision is flagged Current.
func (s *Synthetic) Versions(name string) ([]VersionMeta, error) {
cur, err := s.GetSignal(name)
if err != nil {
return nil, err
}
curV := cur.Version
if curV < 1 {
curV = 1
}
var savedAt time.Time
if info, err := os.Stat(s.defsFilePath()); err == nil {
savedAt = info.ModTime()
}
out := []VersionMeta{{Version: curV, Name: cur.Name, Tag: cur.Tag, Current: true, SavedAt: savedAt}}
entries, err := os.ReadDir(s.versionsDir())
if err != nil && !os.IsNotExist(err) {
return nil, err
}
prefix := slugForFile(name) + ".v"
for _, e := range entries {
fn := e.Name()
if e.IsDir() || !strings.HasPrefix(fn, prefix) || !strings.HasSuffix(fn, ".json") {
continue
}
vStr := strings.TrimSuffix(strings.TrimPrefix(fn, prefix), ".json")
v, err := strconv.Atoi(vStr)
if err != nil {
continue
}
def, err := s.readVersion(name, v)
if err != nil {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, VersionMeta{Version: v, Name: def.Name, Tag: def.Tag, SavedAt: info.ModTime()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
return out, nil
}
// GetVersion returns a specific revision of the named signal. The current
// revision comes from the live store; older revisions from their backup file.
func (s *Synthetic) GetVersion(name string, version int) (SignalDef, error) {
cur, err := s.GetSignal(name)
if err != nil {
return SignalDef{}, err
}
curV := cur.Version
if curV < 1 {
curV = 1
}
if version == curV {
return cur, nil
}
return s.readVersion(name, version)
}
func (s *Synthetic) readVersion(name string, version int) (SignalDef, error) {
data, err := os.ReadFile(s.versionPath(name, version))
if os.IsNotExist(err) {
return SignalDef{}, datasource.ErrNotFound
}
if err != nil {
return SignalDef{}, err
}
var def SignalDef
if err := json.Unmarshal(data, &def); err != nil {
return SignalDef{}, fmt.Errorf("parse revision: %w", err)
}
return def, nil
}
// PromoteVersion makes a past revision current by re-saving it on top of
// history (non-destructive). Returns the resulting current definition.
func (s *Synthetic) PromoteVersion(name string, version int) (SignalDef, error) {
def, err := s.GetVersion(name, version)
if err != nil {
return SignalDef{}, err
}
def.Tag = fmt.Sprintf("restored from v%d", version)
if err := s.UpdateSignal(def); err != nil {
return SignalDef{}, err
}
return s.GetSignal(name)
}
// ForkVersion creates a brand-new synthetic signal from a specific revision,
// assigning a fresh unique name and resetting its version to 1.
func (s *Synthetic) ForkVersion(name string, version int) (SignalDef, error) {
def, err := s.GetVersion(name, version)
if err != nil {
return SignalDef{}, err
}
def.Name = fmt.Sprintf("%s_fork_%d", name, time.Now().UnixMilli())
def.Version = 1
def.Tag = ""
if err := s.AddSignal(def); err != nil {
return SignalDef{}, err
}
return def, nil
}
@@ -0,0 +1,87 @@
package synthetic
import (
"context"
"testing"
)
func TestSyntheticVersioning(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if err := syn.Connect(context.Background()); err != nil {
t.Fatal(err)
}
// defWithGain builds a fresh def each time, mirroring how the REST handler
// decodes a new SignalDef per request (no aliasing of slices/maps).
defWithGain := func(g float64) SignalDef {
return SignalDef{Name: "wf", DS: "stub", Signal: "sine_1hz",
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": g}}}}
}
if err := syn.AddSignal(defWithGain(1.0)); err != nil {
t.Fatalf("AddSignal: %v", err)
}
cur, err := syn.GetSignal("wf")
if err != nil || cur.Version != 1 {
t.Fatalf("after add: version=%d err=%v", cur.Version, err)
}
// Two edits → v2, v3.
if err := syn.UpdateSignal(defWithGain(2.0)); err != nil {
t.Fatalf("UpdateSignal: %v", err)
}
if err := syn.UpdateSignal(defWithGain(3.0)); err != nil {
t.Fatalf("UpdateSignal: %v", err)
}
cur, _ = syn.GetSignal("wf")
if cur.Version != 3 {
t.Fatalf("want current v3, got v%d", cur.Version)
}
versions, err := syn.Versions("wf")
if err != nil {
t.Fatalf("Versions: %v", err)
}
if len(versions) != 3 {
t.Fatalf("want 3 versions, got %d", len(versions))
}
if !versions[0].Current || versions[0].Version != 3 {
t.Errorf("newest should be current v3: %+v", versions[0])
}
// v1 backup retrievable with original gain.
v1, err := syn.GetVersion("wf", 1)
if err != nil {
t.Fatalf("GetVersion v1: %v", err)
}
if v1.Pipeline[0].Params["gain"] != 1.0 {
t.Errorf("v1 gain: want 1.0, got %v", v1.Pipeline[0].Params["gain"])
}
// Promote v1 → becomes v4 (non-destructive).
promoted, err := syn.PromoteVersion("wf", 1)
if err != nil {
t.Fatalf("Promote: %v", err)
}
if promoted.Version != 4 || promoted.Pipeline[0].Params["gain"] != 1.0 {
t.Errorf("promote: version=%d gain=%v", promoted.Version, promoted.Pipeline[0].Params["gain"])
}
// Fork v3 → fresh signal, version 1.
forked, err := syn.ForkVersion("wf", 3)
if err != nil {
t.Fatalf("Fork: %v", err)
}
if forked.Version != 1 || forked.Name == "wf" {
t.Errorf("fork: name=%q version=%d", forked.Name, forked.Version)
}
if forked.Pipeline[0].Params["gain"] != 3.0 {
t.Errorf("fork gain: want 3.0, got %v", forked.Pipeline[0].Params["gain"])
}
if _, err := syn.GetSignal(forked.Name); err != nil {
t.Errorf("forked signal not registered: %v", err)
}
}