Files
poimen-workflows/internal/history/pruner.go
T

333 lines
8.6 KiB
Go
Raw Normal View History

package history
import (
"encoding/json"
"fmt"
"os"
"sort"
"sync"
"time"
)
// TaskHistory represents a single task execution in history
type TaskHistory struct {
TaskID string `json:"task_id"`
Status string `json:"status"` // "pending", "completed", "failed"
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Duration time.Duration `json:"duration"`
Output map[string]interface{} `json:"output,omitempty"`
Error string `json:"error,omitempty"`
Metrics map[string]interface{} `json:"metrics,omitempty"`
Size int64 `json:"size"` // Estimated size in bytes
}
// PrunePolicy defines how to prune history
type PrunePolicy struct {
MaxHistorySize int64 // Max total history size in bytes (e.g., 100MB)
MaxHistoryAge time.Duration // Max age of history entries (e.g., 24 hours)
MaxEntries int // Max number of entries to keep (e.g., 1000)
ArchiveDir string // Directory to archive pruned items
}
// HistoryPruner manages workflow history with automatic pruning
type HistoryPruner struct {
mu sync.RWMutex
entries []*TaskHistory
policy PrunePolicy
totalSize int64
pruneCount int
archiveCount int
lastPruneTime time.Time
pruneThreshold int64 // Size threshold that triggers pruning
}
// NewHistoryPruner creates a new history pruner
func NewHistoryPruner(policy PrunePolicy) *HistoryPruner {
if policy.MaxHistorySize == 0 {
policy.MaxHistorySize = 100 * 1024 * 1024 // 100MB default
}
if policy.MaxHistoryAge == 0 {
policy.MaxHistoryAge = 24 * time.Hour // 24 hours default
}
if policy.MaxEntries == 0 {
policy.MaxEntries = 1000 // 1000 entries default
}
// Set prune threshold at 90% of max size
pruneThreshold := (policy.MaxHistorySize * 9) / 10
return &HistoryPruner{
entries: make([]*TaskHistory, 0),
policy: policy,
pruneThreshold: pruneThreshold,
}
}
// AddEntry adds a task history entry
func (hp *HistoryPruner) AddEntry(entry *TaskHistory) error {
if entry == nil {
return fmt.Errorf("entry cannot be nil")
}
hp.mu.Lock()
defer hp.mu.Unlock()
// Estimate size
data, _ := json.Marshal(entry)
entry.Size = int64(len(data))
hp.entries = append(hp.entries, entry)
hp.totalSize += entry.Size
// Check if pruning is needed
if hp.totalSize > hp.pruneThreshold || len(hp.entries) > hp.policy.MaxEntries {
hp.pruneLocked()
}
return nil
}
// pruneLocked prunes old entries based on policy (must be called with lock held)
func (hp *HistoryPruner) pruneLocked() {
if len(hp.entries) == 0 {
return
}
// Sort by end time (oldest first)
sort.Slice(hp.entries, func(i, j int) bool {
return hp.entries[i].EndTime.Before(hp.entries[j].EndTime)
})
// Archive old entries
var toKeep []*TaskHistory
newTotalSize := int64(0)
now := time.Now()
for _, entry := range hp.entries {
age := now.Sub(entry.EndTime)
// Keep if:
// 1. Newer than max age, AND
// 2. Total size not exceeded, AND
// 3. Not too many entries
if age < hp.policy.MaxHistoryAge &&
newTotalSize+entry.Size <= hp.policy.MaxHistorySize &&
len(toKeep) < hp.policy.MaxEntries {
toKeep = append(toKeep, entry)
newTotalSize += entry.Size
} else {
// Archive this entry
hp.archiveEntry(entry)
hp.archiveCount++
}
}
hp.entries = toKeep
hp.totalSize = newTotalSize
hp.pruneCount++
hp.lastPruneTime = time.Now()
}
// archiveEntry archives an entry to disk (must be called with lock held)
func (hp *HistoryPruner) archiveEntry(entry *TaskHistory) {
if hp.policy.ArchiveDir == "" {
return // No archive directory configured
}
// Create archive directory if it doesn't exist
_ = os.MkdirAll(hp.policy.ArchiveDir, 0755)
// Save entry to archive file
timestamp := time.Now().Unix()
archivePath := fmt.Sprintf("%s/history-%s-%d.json", hp.policy.ArchiveDir, entry.TaskID, timestamp)
data, _ := json.MarshalIndent(entry, "", " ")
_ = os.WriteFile(archivePath, data, 0644)
}
// Prune manually triggers pruning
func (hp *HistoryPruner) Prune() {
hp.mu.Lock()
defer hp.mu.Unlock()
hp.pruneLocked()
}
// GetSize returns total history size
func (hp *HistoryPruner) GetSize() int64 {
hp.mu.RLock()
defer hp.mu.RUnlock()
return hp.totalSize
}
// GetEntryCount returns number of entries in history
func (hp *HistoryPruner) GetEntryCount() int {
hp.mu.RLock()
defer hp.mu.RUnlock()
return len(hp.entries)
}
// GetStats returns pruning statistics
func (hp *HistoryPruner) GetStats() map[string]interface{} {
hp.mu.RLock()
defer hp.mu.RUnlock()
avgEntrySize := int64(0)
if len(hp.entries) > 0 {
avgEntrySize = hp.totalSize / int64(len(hp.entries))
}
return map[string]interface{}{
"total_size": hp.totalSize,
"entry_count": len(hp.entries),
"avg_entry_size": avgEntrySize,
"max_allowed_size": hp.policy.MaxHistorySize,
"max_allowed_age": hp.policy.MaxHistoryAge,
"max_allowed_entries": hp.policy.MaxEntries,
"prune_count": hp.pruneCount,
"archive_count": hp.archiveCount,
"last_prune_time": hp.lastPruneTime,
"usage_ratio": float64(hp.totalSize) / float64(hp.policy.MaxHistorySize),
}
}
// GetEntries returns a copy of all entries
func (hp *HistoryPruner) GetEntries() []*TaskHistory {
hp.mu.RLock()
defer hp.mu.RUnlock()
result := make([]*TaskHistory, len(hp.entries))
copy(result, hp.entries)
return result
}
// GetEntriesByStatus returns entries filtered by status
func (hp *HistoryPruner) GetEntriesByStatus(status string) []*TaskHistory {
hp.mu.RLock()
defer hp.mu.RUnlock()
var result []*TaskHistory
for _, entry := range hp.entries {
if entry.Status == status {
result = append(result, entry)
}
}
return result
}
// GetRecentEntries returns the most recent N entries
func (hp *HistoryPruner) GetRecentEntries(count int) []*TaskHistory {
hp.mu.RLock()
defer hp.mu.RUnlock()
if count > len(hp.entries) {
count = len(hp.entries)
}
// Sort by end time descending (newest first)
sorted := make([]*TaskHistory, len(hp.entries))
copy(sorted, hp.entries)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].EndTime.After(sorted[j].EndTime)
})
return sorted[:count]
}
// Clear clears all history
func (hp *HistoryPruner) Clear() {
hp.mu.Lock()
defer hp.mu.Unlock()
hp.entries = make([]*TaskHistory, 0)
hp.totalSize = 0
}
// GetEntry returns a specific entry by task ID
func (hp *HistoryPruner) GetEntry(taskID string) (*TaskHistory, bool) {
hp.mu.RLock()
defer hp.mu.RUnlock()
for _, entry := range hp.entries {
if entry.TaskID == taskID {
return entry, true
}
}
return nil, false
}
// CalculateMemorySavings calculates estimated memory saved by pruning
func (hp *HistoryPruner) CalculateMemorySavings() int64 {
hp.mu.RLock()
defer hp.mu.RUnlock()
// Estimated savings: total pruned size minus current size
// This is an approximation based on how much was archived
savedSize := int64(hp.archiveCount) * (hp.totalSize / int64(len(hp.entries) + 1))
return savedSize
}
// ShouldPrune checks if pruning is needed
func (hp *HistoryPruner) ShouldPrune() bool {
hp.mu.RLock()
defer hp.mu.RUnlock()
return hp.totalSize > hp.pruneThreshold || len(hp.entries) > hp.policy.MaxEntries
}
// GetMemoryInfo returns memory usage information
func (hp *HistoryPruner) GetMemoryInfo() map[string]interface{} {
hp.mu.RLock()
defer hp.mu.RUnlock()
return map[string]interface{}{
"current_size": hp.totalSize,
"max_size": hp.policy.MaxHistorySize,
"current_entries": len(hp.entries),
"max_entries": hp.policy.MaxEntries,
"usage_percentage": float64(hp.totalSize*100) / float64(hp.policy.MaxHistorySize),
"entries_percentage": float64(len(hp.entries)*100) / float64(hp.policy.MaxEntries),
}
}
// UpdateEntry updates an existing entry
func (hp *HistoryPruner) UpdateEntry(taskID string, updates map[string]interface{}) error {
hp.mu.Lock()
defer hp.mu.Unlock()
for _, entry := range hp.entries {
if entry.TaskID == taskID {
// Apply updates
for key, value := range updates {
switch key {
case "status":
entry.Status = value.(string)
case "output":
entry.Output = value.(map[string]interface{})
case "error":
entry.Error = value.(string)
case "end_time":
entry.EndTime = value.(time.Time)
entry.Duration = entry.EndTime.Sub(entry.StartTime)
}
}
// Recalculate size
data, _ := json.Marshal(entry)
newSize := int64(len(data))
hp.totalSize = hp.totalSize - entry.Size + newSize
entry.Size = newSize
return nil
}
}
return fmt.Errorf("entry not found: %s", taskID)
}