739eabc49a
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
332 lines
11 KiB
Go
332 lines
11 KiB
Go
package retention
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/db"
|
|
)
|
|
|
|
// TableSweepResult is one reported table's outcome for one sweep
|
|
// (AC-OFFICE-RUN-HISTORY-RETENTION-004.6). Backlog is only ever true for a
|
|
// swept table's own SweptTableResult; a satellite table's Backlog stays
|
|
// false because its deletion is never independently batch-limited — it
|
|
// deletes exactly the run ids its owning runs batch selected.
|
|
type TableSweepResult struct {
|
|
Deleted int64 `json:"deleted"`
|
|
Backlog bool `json:"backlog"`
|
|
Err string `json:"error"`
|
|
}
|
|
|
|
// SweptTableResult adds preview reporting to a swept table's outcome.
|
|
// Previewed is true only when THIS sweep was a preview pass for this
|
|
// table, never a running total.
|
|
type SweptTableResult struct {
|
|
TableSweepResult
|
|
Previewed bool `json:"previewed"`
|
|
WouldDelete int64 `json:"would_delete"`
|
|
}
|
|
|
|
// LastSweep is the in-memory value replaced wholesale at the end of each
|
|
// sweep that actually ran (AC-OFFICE-RUN-HISTORY-RETENTION-004.6, -004.7).
|
|
// Nothing here is persisted.
|
|
type LastSweep struct {
|
|
StartedAt time.Time `json:"started_at"`
|
|
FinishedAt time.Time `json:"finished_at"`
|
|
|
|
OfficeRoutineRuns SweptTableResult `json:"office_routine_runs"`
|
|
Runs SweptTableResult `json:"runs"`
|
|
RunEvents TableSweepResult `json:"run_events"`
|
|
RouteAttempts TableSweepResult `json:"route_attempts"`
|
|
RunSkills TableSweepResult `json:"run_skills"`
|
|
}
|
|
|
|
type satelliteResults struct {
|
|
RunEvents TableSweepResult
|
|
RouteAttempts TableSweepResult
|
|
RunSkills TableSweepResult
|
|
}
|
|
|
|
// Sweeper runs one sweep or one census pass at a time; the scheduler
|
|
// (scheduler.go) owns when to call each.
|
|
//
|
|
// - Lost-exclusivity outcome: a lock lost between tables abandons the
|
|
// whole sweep attempt as a skip — the same outcome as a local
|
|
// concurrent-sweep collision — rather than inventing a per-table
|
|
// "skipped" state the design's eight-id health catalogue has nowhere
|
|
// to report. Batches already committed on PostgreSQL stay committed
|
|
// (AC-002.5); LastSweep simply is not replaced by this attempt, and
|
|
// the next scheduled sweep reports the tables' true state either way.
|
|
// - The skip counter increments for a collision, a failed settings
|
|
// re-read, a failed lock acquisition, and a lock lost mid-sweep — every
|
|
// case where a sweep was due and did not produce a result. It does
|
|
// NOT increment when retention is disabled (AC-002.8's "run no sweep"
|
|
// is a deliberate, steady-state condition, not a due sweep that could
|
|
// not run; incrementing forever while off would make the counter
|
|
// meaningless).
|
|
type Sweeper struct {
|
|
pool *db.Pool
|
|
store *Store
|
|
settingsStore *SettingsStore
|
|
previewMarker *PreviewMarkerStore
|
|
census *CensusTracker
|
|
|
|
mu sync.Mutex
|
|
sweeping bool
|
|
lastSweep *LastSweep
|
|
skipCount int64
|
|
lastSkip time.Time
|
|
}
|
|
|
|
// NewSweeper wires the sweep orchestration to its dependencies.
|
|
func NewSweeper(pool *db.Pool, store *Store, settingsStore *SettingsStore, previewMarker *PreviewMarkerStore) *Sweeper {
|
|
return &Sweeper{
|
|
pool: pool,
|
|
store: store,
|
|
settingsStore: settingsStore,
|
|
previewMarker: previewMarker,
|
|
census: NewCensusTracker(),
|
|
}
|
|
}
|
|
|
|
// LastSweepSnapshot returns the most recent completed sweep's result. ok is
|
|
// false before the first sweep ever completes (AC-OFFICE-RUN-HISTORY-RETENTION-004.7).
|
|
func (s *Sweeper) LastSweepSnapshot() (LastSweep, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.lastSweep == nil {
|
|
return LastSweep{}, false
|
|
}
|
|
return *s.lastSweep, true
|
|
}
|
|
|
|
// SkipSnapshot returns the running skip count and the last skip's time.
|
|
func (s *Sweeper) SkipSnapshot() (count int64, lastAt time.Time) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.skipCount, s.lastSkip
|
|
}
|
|
|
|
// CensusSnapshot returns the current per-table retained counts.
|
|
func (s *Sweeper) CensusSnapshot() RetainedCounts {
|
|
return s.census.Snapshot()
|
|
}
|
|
|
|
// RunSweep performs at most one sweep: office_routine_runs, then runs with
|
|
// its satellites, in that fixed order (AC-OFFICE-RUN-HISTORY-RETENTION-002.6).
|
|
func (s *Sweeper) RunSweep(ctx context.Context) {
|
|
if !s.beginSweep() {
|
|
s.recordSkip()
|
|
return
|
|
}
|
|
defer s.endSweep()
|
|
|
|
settings, err := s.settingsStore.GetSettingsForSweep(ctx)
|
|
if err != nil {
|
|
// Settings unreadable at sweep start fails closed: no sweep, no
|
|
// fallback to the (possibly shorter) default window.
|
|
s.recordSkip()
|
|
return
|
|
}
|
|
if !settings.Enabled {
|
|
// AC-002.8: disabled means no sweep at all, not a recorded skip.
|
|
return
|
|
}
|
|
|
|
q, session, ok := s.acquireQueryer(ctx)
|
|
if !ok {
|
|
s.recordSkip()
|
|
return
|
|
}
|
|
if session != nil {
|
|
defer session.release()
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
report := LastSweep{StartedAt: now}
|
|
report.OfficeRoutineRuns = s.sweepRoutineRuns(ctx, q, settings.RoutineRuns, now, settings.BatchLimit)
|
|
|
|
if testBetweenTablesSweep != nil {
|
|
testBetweenTablesSweep(q)
|
|
}
|
|
if session != nil && !session.alive(ctx) {
|
|
// Exclusivity was lost after the first table's work. Do not
|
|
// start the second table, and do not publish a partial result —
|
|
// this whole attempt is a skip, exactly as if the lock had
|
|
// never been acquired.
|
|
s.recordSkip()
|
|
return
|
|
}
|
|
|
|
runsResult, satellites := s.sweepRuns(ctx, q, settings.Runs, now, settings.BatchLimit)
|
|
report.Runs = runsResult
|
|
report.RunEvents = satellites.RunEvents
|
|
report.RouteAttempts = satellites.RouteAttempts
|
|
report.RunSkills = satellites.RunSkills
|
|
|
|
report.FinishedAt = time.Now().UTC()
|
|
s.mu.Lock()
|
|
s.lastSweep = &report
|
|
s.mu.Unlock()
|
|
|
|
incSweepCompleted()
|
|
incDeleted(TableOfficeRoutineRuns, report.OfficeRoutineRuns.Deleted)
|
|
incDeleted(TableRuns, report.Runs.Deleted)
|
|
incDeleted(TableRunEvents, report.RunEvents.Deleted)
|
|
incDeleted("office_run_route_attempts", report.RouteAttempts.Deleted)
|
|
incDeleted("office_run_skills", report.RunSkills.Deleted)
|
|
}
|
|
|
|
// testBetweenTablesSweep, when set, runs right after office_routine_runs'
|
|
// table work and right before the alive() liveness check and the runs
|
|
// table — a deterministic seam for exercising AC-OFFICE-RUN-HISTORY-RETENTION-002.12's
|
|
// "verifies the lock connection is still alive between tables" path
|
|
// without depending on real cross-process timing. It receives the sweep's
|
|
// own queryer so a test can run diagnostics (or a second sweep attempt)
|
|
// against the exact connection in use. Never set outside tests.
|
|
var testBetweenTablesSweep func(q queryer)
|
|
|
|
// RunCensus evaluates the retained-count census for every thresholded
|
|
// table. Read-only, so it needs no advisory lock: every backend computes
|
|
// and serves its own local view.
|
|
func (s *Sweeper) RunCensus(ctx context.Context) {
|
|
q := s.pool.Reader()
|
|
now := time.Now().UTC()
|
|
|
|
routineCensus, err := s.store.CensusRoutineRuns(ctx, q, now)
|
|
s.census.RecordRoutineRuns(routineCensus, err)
|
|
incCensus(TableOfficeRoutineRuns, err)
|
|
|
|
runsCensus, err := s.store.CensusRuns(ctx, q, now)
|
|
s.census.RecordRuns(runsCensus, err)
|
|
incCensus(TableRuns, err)
|
|
|
|
runEventsCensus, err := s.store.CensusRunEvents(ctx, q, now)
|
|
s.census.RecordRunEvents(runEventsCensus, err)
|
|
incCensus(TableRunEvents, err)
|
|
}
|
|
|
|
func (s *Sweeper) beginSweep() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.sweeping {
|
|
return false
|
|
}
|
|
s.sweeping = true
|
|
return true
|
|
}
|
|
|
|
func (s *Sweeper) endSweep() {
|
|
s.mu.Lock()
|
|
s.sweeping = false
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *Sweeper) recordSkip() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.skipCount++
|
|
s.lastSkip = time.Now().UTC()
|
|
incSweepSkipped()
|
|
}
|
|
|
|
func (s *Sweeper) acquireQueryer(ctx context.Context) (queryer, *sweepSession, bool) {
|
|
if !s.store.IsPostgres() {
|
|
return s.pool.Writer(), nil, true
|
|
}
|
|
session, ok, err := acquireSweepSession(ctx, s.pool)
|
|
if err != nil || !ok {
|
|
return nil, nil, false
|
|
}
|
|
return session.queryer(), session, true
|
|
}
|
|
|
|
// isPreviewed reads the preview marker through q, the sweep's own
|
|
// connection, rather than the shared settings pool (see the
|
|
// PreviewMarkerStore.GetWith doc comment).
|
|
func (s *Sweeper) isPreviewed(ctx context.Context, q queryer, table TableName) bool {
|
|
marker, _ := s.previewMarker.GetWith(ctx, q)
|
|
_, ok := marker[table]
|
|
return ok
|
|
}
|
|
|
|
func (s *Sweeper) sweepRoutineRuns(ctx context.Context, q queryer, cfg TableSettings, now time.Time, batchLimit int) SweptTableResult {
|
|
cutoff := retentionCutoff(now, cfg.WindowDays)
|
|
if !s.isPreviewed(ctx, q, TableOfficeRoutineRuns) {
|
|
return s.previewTable(ctx, q, TableOfficeRoutineRuns, func() (int64, error) {
|
|
return s.store.CountEligibleRoutineRuns(ctx, q, cutoff, cfg.FloorPerOwner)
|
|
}, now)
|
|
}
|
|
|
|
eligible, err := s.store.CountEligibleRoutineRuns(ctx, q, cutoff, cfg.FloorPerOwner)
|
|
if err != nil {
|
|
return SweptTableResult{TableSweepResult: TableSweepResult{Err: err.Error()}}
|
|
}
|
|
deleted, err := s.store.DeleteRoutineRunsBatch(ctx, q, cutoff, cfg.FloorPerOwner, batchLimit)
|
|
if err != nil {
|
|
return SweptTableResult{TableSweepResult: TableSweepResult{Err: err.Error()}}
|
|
}
|
|
return SweptTableResult{TableSweepResult: TableSweepResult{
|
|
Deleted: deleted,
|
|
Backlog: eligible > int64(batchLimit),
|
|
}}
|
|
}
|
|
|
|
func (s *Sweeper) sweepRuns(ctx context.Context, q queryer, cfg TableSettings, now time.Time, batchLimit int) (SweptTableResult, satelliteResults) {
|
|
cutoff := retentionCutoff(now, cfg.WindowDays)
|
|
if !s.isPreviewed(ctx, q, TableRuns) {
|
|
result := s.previewTable(ctx, q, TableRuns, func() (int64, error) {
|
|
return s.store.CountEligibleRuns(ctx, q, cutoff, cfg.FloorPerOwner)
|
|
}, now)
|
|
return result, satelliteResults{}
|
|
}
|
|
|
|
eligible, err := s.store.CountEligibleRuns(ctx, q, cutoff, cfg.FloorPerOwner)
|
|
if err != nil {
|
|
return SweptTableResult{TableSweepResult: TableSweepResult{Err: err.Error()}}, satelliteResults{}
|
|
}
|
|
result, err := s.store.DeleteRunBatch(ctx, q, cutoff, cfg.FloorPerOwner, batchLimit)
|
|
if err != nil {
|
|
return SweptTableResult{TableSweepResult: TableSweepResult{Err: err.Error()}}, satelliteResults{}
|
|
}
|
|
if result.Abandoned {
|
|
// AC-002.7: an abandoned batch is that table's failure, not
|
|
// backlog; the satellites report zero, matching what the
|
|
// rollback actually left committed.
|
|
return SweptTableResult{TableSweepResult: TableSweepResult{
|
|
Err: "batch abandoned after two consecutive mismatches",
|
|
}}, satelliteResults{}
|
|
}
|
|
return SweptTableResult{TableSweepResult: TableSweepResult{
|
|
Deleted: result.RunsDeleted,
|
|
Backlog: eligible > int64(batchLimit),
|
|
}}, satelliteResults{
|
|
RunEvents: TableSweepResult{Deleted: result.RunEventsDeleted},
|
|
RouteAttempts: TableSweepResult{Deleted: result.RouteAttemptsDeleted},
|
|
RunSkills: TableSweepResult{Deleted: result.RunSkillsDeleted},
|
|
}
|
|
}
|
|
|
|
// retentionCutoff turns a table's configured window into the instant a
|
|
// history row's completion time must be older than to be eligible,
|
|
// derived from the one sweep-start instant both tables share
|
|
// (AC-OFFICE-RUN-HISTORY-RETENTION-002.11).
|
|
func retentionCutoff(now time.Time, windowDays int) time.Time {
|
|
return now.AddDate(0, 0, -windowDays)
|
|
}
|
|
|
|
// previewTable runs a table's first-ever preview pass: count eligible rows
|
|
// uncapped, mark the table previewed, and delete nothing
|
|
// (AC-OFFICE-RUN-HISTORY-RETENTION-003.2, -003.9).
|
|
func (s *Sweeper) previewTable(ctx context.Context, q queryer, table TableName, countEligible func() (int64, error), now time.Time) SweptTableResult {
|
|
count, err := countEligible()
|
|
if err != nil {
|
|
return SweptTableResult{TableSweepResult: TableSweepResult{Err: err.Error()}}
|
|
}
|
|
if err := s.previewMarker.MarkCompletedWith(ctx, q, table, now); err != nil {
|
|
return SweptTableResult{TableSweepResult: TableSweepResult{Err: err.Error()}}
|
|
}
|
|
return SweptTableResult{Previewed: true, WouldDelete: count}
|
|
}
|