mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Serialize remediation history persistence
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user