From a4f1fe830282dc7991675ac1ca8308a354cf6a57 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 8 Apr 2026 11:51:52 +0700 Subject: [PATCH] checker: add Janitor goroutine to enforce retention policy The Janitor periodically walks every CheckPlan, loads its executions, and deletes the ones that the tiered RetentionPolicy says to drop. Per-user overrides are honoured: if a user's UserQuota.RetentionDays is set, that horizon replaces the system default for the user's plans. User lookups are cached per sweep to avoid repeated storage hits. The janitor is the long-tail counterpart of the (still TODO) cheap hard cap that will be applied at execution-creation time. It runs immediately on Start() and then every configured interval (default 6h). --- internal/usecase/checker/janitor.go | 185 ++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 internal/usecase/checker/janitor.go diff --git a/internal/usecase/checker/janitor.go b/internal/usecase/checker/janitor.go new file mode 100644 index 00000000..5dff150a --- /dev/null +++ b/internal/usecase/checker/janitor.go @@ -0,0 +1,185 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package checker + +import ( + "context" + "log" + "sync" + "time" + + "git.happydns.org/happyDomain/model" +) + +// JanitorUserResolver resolves a user from a CheckTarget so the janitor can +// honour per-user retention overrides stored in UserQuota. +type JanitorUserResolver interface { + GetUser(id happydns.Identifier) (*happydns.User, error) +} + +// Janitor periodically prunes old check executions according to the tiered +// RetentionPolicy. It is the long-tail enforcement counterpart of the cheap +// hard cap applied at execution-creation time. +type Janitor struct { + planStore CheckPlanStorage + execStore ExecutionStorage + userResolver JanitorUserResolver + defaultPolicy RetentionPolicy + interval time.Duration + + mu sync.Mutex + cancel context.CancelFunc + running bool +} + +// NewJanitor builds a Janitor that runs every `interval`. The defaultPolicy +// is applied to executions of users that did not customize their retention +// horizon via UserQuota. +func NewJanitor(planStore CheckPlanStorage, execStore ExecutionStorage, userResolver JanitorUserResolver, defaultPolicy RetentionPolicy, interval time.Duration) *Janitor { + if interval <= 0 { + interval = 6 * time.Hour + } + return &Janitor{ + planStore: planStore, + execStore: execStore, + userResolver: userResolver, + defaultPolicy: defaultPolicy, + interval: interval, + } +} + +// Start launches the janitor loop in a goroutine. It runs an immediate sweep +// once the loop is up. +func (j *Janitor) Start(ctx context.Context) { + j.mu.Lock() + if j.running { + j.mu.Unlock() + return + } + ctx, cancel := context.WithCancel(ctx) + j.cancel = cancel + j.running = true + j.mu.Unlock() + + go j.loop(ctx) +} + +// Stop halts the janitor. +func (j *Janitor) Stop() { + j.mu.Lock() + defer j.mu.Unlock() + if j.cancel != nil { + j.cancel() + } + j.running = false +} + +func (j *Janitor) loop(ctx context.Context) { + // Run immediately, then on the configured interval. + j.RunOnce(ctx) + + ticker := time.NewTicker(j.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + j.RunOnce(ctx) + } + } +} + +// RunOnce performs a single sweep over all check plans, applying the per-user +// retention policy. Returns the number of executions deleted. +func (j *Janitor) RunOnce(ctx context.Context) int { + iter, err := j.planStore.ListAllCheckPlans() + if err != nil { + log.Printf("Janitor: failed to list check plans: %v", err) + return 0 + } + + now := time.Now() + deleted := 0 + + // Cache user policies to avoid resolving the same user repeatedly. + policyByUser := map[string]RetentionPolicy{} + + for iter.Next() { + select { + case <-ctx.Done(): + return deleted + default: + } + + plan := iter.Item() + if plan == nil { + continue + } + + execs, err := j.execStore.ListExecutionsByPlan(plan.Id) + if err != nil { + log.Printf("Janitor: failed to list executions for plan %s: %v", plan.Id.String(), err) + continue + } + if len(execs) == 0 { + continue + } + + policy := j.policyForTarget(plan.Target, policyByUser) + _, drop := policy.Decide(execs, now) + + for _, id := range drop { + if err := j.execStore.DeleteExecution(id); err != nil { + log.Printf("Janitor: failed to delete execution %s: %v", id.String(), err) + continue + } + deleted++ + } + } + + if deleted > 0 { + log.Printf("Janitor: pruned %d executions", deleted) + } + return deleted +} + +func (j *Janitor) policyForTarget(target happydns.CheckTarget, cache map[string]RetentionPolicy) RetentionPolicy { + uid := target.UserId + if uid == "" || j.userResolver == nil { + return j.defaultPolicy + } + if p, ok := cache[uid]; ok { + return p + } + policy := j.defaultPolicy + id, err := happydns.NewIdentifierFromString(uid) + if err == nil { + if user, err := j.userResolver.GetUser(id); err == nil && user != nil { + if user.Quota.RetentionDays > 0 { + policy = DefaultRetentionPolicy(user.Quota.RetentionDays) + } + } + } + cache[uid] = policy + return policy +}