Serialize remediation history persistence

This commit is contained in:
rcourtman
2026-08-05 11:48:08 +01:00
parent 9579968952
commit 4e618cd393
3 changed files with 81 additions and 3 deletions
@@ -4063,6 +4063,13 @@ unchanged configuration remains cacheable during the process lifetime while
credential rotation invalidates the cache without creating an offline
credential verifier.
Remediation-history persistence is a serialized snapshot boundary. Async log
and rollback saves must take a deep copy of rollback metadata while holding the
record lock, then serialize and atomically replace the history file under one
persistence lock. Concurrent saves must never marshal shared rollback pointers,
race over the common temporary path, or allow an older async writer to replace
newer in-memory state.
The canonical findings store must present one active Patrol issue per real
problem. When the model reports an equivalent active sibling under a different
finding ID, key, or severity for the same resource, `internal/ai/findings.go`
@@ -1,6 +1,8 @@
package memory
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
@@ -267,6 +269,50 @@ func TestBranchcov0723pmMarkRolledBack(t *testing.T) {
})
}
func TestRemediationLogConcurrentPersistenceKeepsLatestState(t *testing.T) {
dataDir := t.TempDir()
remediationLog := NewRemediationLog(RemediationLogConfig{DataDir: dataDir})
const recordCount = 32
for i := 0; i < recordCount; i++ {
id := fmt.Sprintf("rec-%d", i)
if err := remediationLog.Log(RemediationRecord{
ID: id,
Problem: "p",
Action: "a",
Timestamp: time.Now(),
Rollback: &RollbackInfo{Reversible: true},
}); err != nil {
t.Fatalf("Log(%s): %v", id, err)
}
if err := remediationLog.MarkRolledBack(id, "rollback-"+id, "operator"); err != nil {
t.Fatalf("MarkRolledBack(%s): %v", id, err)
}
}
if err := remediationLog.saveToDisk(); err != nil {
t.Fatalf("saveToDisk: %v", err)
}
waitForRemediationSaveQuiescence(t, dataDir)
data, err := os.ReadFile(filepath.Join(dataDir, remediationHistoryFileName))
if err != nil {
t.Fatalf("read remediation history: %v", err)
}
var records []RemediationRecord
if err := json.Unmarshal(data, &records); err != nil {
t.Fatalf("unmarshal remediation history: %v", err)
}
if len(records) != recordCount {
t.Fatalf("expected %d persisted records, got %d", recordCount, len(records))
}
for _, record := range records {
if record.Rollback == nil || !record.Rollback.RolledBack {
t.Fatalf("record %q did not persist its latest rollback state", record.ID)
}
}
}
func TestBranchcov0723pmGetRollbackable(t *testing.T) {
// eligibleRecord builds a record that satisfies GetRollbackable's
// eligibility condition: Rollback != nil, Reversible, not rolled back,
+28 -3
View File
@@ -55,7 +55,9 @@ type RemediationRecord struct {
// RemediationLog stores remediation history
type RemediationLog struct {
mu sync.RWMutex
mu sync.RWMutex
persistMu sync.Mutex
records []RemediationRecord
maxRecords int
@@ -287,9 +289,14 @@ func (r *RemediationLog) saveToDisk() error {
return nil
}
// Async callers share one destination and temporary path. Serialize the
// complete persistence operation so an older goroutine cannot race a newer
// snapshot or rename another writer's temporary file.
r.persistMu.Lock()
defer r.persistMu.Unlock()
r.mu.RLock()
records := make([]RemediationRecord, len(r.records))
copy(records, r.records)
records := cloneRemediationRecords(r.records)
r.mu.RUnlock()
path, err := memoryPersistencePath(r.dataDir, remediationHistoryFileName)
@@ -309,6 +316,24 @@ func (r *RemediationLog) saveToDisk() error {
return os.Rename(tmpPath, path)
}
func cloneRemediationRecords(records []RemediationRecord) []RemediationRecord {
cloned := make([]RemediationRecord, len(records))
for i := range records {
cloned[i] = records[i]
if records[i].Rollback == nil {
continue
}
rollback := *records[i].Rollback
if records[i].Rollback.RolledBackAt != nil {
rolledBackAt := *records[i].Rollback.RolledBackAt
rollback.RolledBackAt = &rolledBackAt
}
cloned[i].Rollback = &rollback
}
return cloned
}
// loadFromDisk loads records from JSON file
func (r *RemediationLog) loadFromDisk() error {
records, ok, err := loadMemoryHistory(r.dataDir, remediationHistoryFileName, "remediation history", func(a, b RemediationRecord) bool {