333 lines
8.6 KiB
Go
333 lines
8.6 KiB
Go
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)
|
||
|
|
}
|