Implement tests scheduler

This commit is contained in:
nemunaire 2026-02-10 10:33:12 +07:00
commit b2616c5c3a
9 changed files with 1155 additions and 0 deletions

View file

@ -0,0 +1,231 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package controller
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// CheckerScheduleController handles test schedule operations
type CheckerScheduleController struct {
testScheduleUC happydns.CheckerScheduleUsecase
}
func NewCheckerScheduleController(testScheduleUC happydns.CheckerScheduleUsecase) *CheckerScheduleController {
return &CheckerScheduleController{
testScheduleUC: testScheduleUC,
}
}
// ListCheckerSchedules retrieves schedules for the authenticated user
//
// @Summary List test schedules
// @Description Retrieves test schedules for the authenticated user with optional pagination
// @Tags test-schedules
// @Produce json
// @Param limit query int false "Maximum number of schedules to return (0 = all)"
// @Param offset query int false "Number of schedules to skip (default: 0)"
// @Success 200 {array} happydns.CheckerSchedule
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules [get]
func (tc *CheckerScheduleController) ListCheckerSchedules(c *gin.Context) {
user := middleware.MyUser(c)
schedules, err := tc.testScheduleUC.ListUserSchedules(user.Id)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Apply pagination
limit := 0
offset := 0
fmt.Sscanf(c.Query("limit"), "%d", &limit)
fmt.Sscanf(c.Query("offset"), "%d", &offset)
if offset > len(schedules) {
offset = len(schedules)
}
schedules = schedules[offset:]
if limit > 0 && len(schedules) > limit {
schedules = schedules[:limit]
}
c.JSON(http.StatusOK, schedules)
}
// CreateCheckerSchedule creates a new test schedule
//
// @Summary Create test schedule
// @Description Creates a new test schedule for the authenticated user
// @Tags test-schedules
// @Accept json
// @Produce json
// @Param body body happydns.CheckerSchedule true "Check schedule to create"
// @Success 201 {object} happydns.CheckerSchedule
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules [post]
func (tc *CheckerScheduleController) CreateCheckerSchedule(c *gin.Context) {
user := middleware.MyUser(c)
var schedule happydns.CheckerSchedule
if err := c.ShouldBindJSON(&schedule); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
// Set user ID
schedule.OwnerId = user.Id
if err := tc.testScheduleUC.CreateSchedule(&schedule); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusCreated, schedule)
}
// GetCheckerSchedule retrieves a specific schedule
//
// @Summary Get test schedule
// @Description Retrieves a specific test schedule by ID
// @Tags test-schedules
// @Produce json
// @Param schedule_id path string true "Schedule ID"
// @Success 200 {object} happydns.CheckerSchedule
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules/{schedule_id} [get]
func (tc *CheckerScheduleController) GetCheckerSchedule(c *gin.Context) {
user := middleware.MyUser(c)
scheduleIdStr := c.Param("schedule_id")
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
return
}
// Verify ownership
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
middleware.ErrorResponse(c, http.StatusForbidden, err)
return
}
schedule, err := tc.testScheduleUC.GetSchedule(scheduleId)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, schedule)
}
// UpdateCheckerSchedule updates an existing schedule
//
// @Summary Update test schedule
// @Description Updates an existing test schedule
// @Tags test-schedules
// @Accept json
// @Produce json
// @Param schedule_id path string true "Schedule ID"
// @Param body body happydns.CheckerSchedule true "Updated schedule"
// @Success 200 {object} happydns.CheckerSchedule
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules/{schedule_id} [put]
func (tc *CheckerScheduleController) UpdateCheckerSchedule(c *gin.Context) {
user := middleware.MyUser(c)
scheduleIdStr := c.Param("schedule_id")
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
return
}
// Verify ownership
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
middleware.ErrorResponse(c, http.StatusForbidden, err)
return
}
var schedule happydns.CheckerSchedule
if err := c.ShouldBindJSON(&schedule); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
// Ensure ID matches
schedule.Id = scheduleId
schedule.OwnerId = user.Id
if err := tc.testScheduleUC.UpdateSchedule(&schedule); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, schedule)
}
// DeleteCheckerSchedule deletes a schedule
//
// @Summary Delete test schedule
// @Description Deletes a test schedule
// @Tags test-schedules
// @Produce json
// @Param schedule_id path string true "Schedule ID"
// @Success 204 "No Content"
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules/{schedule_id} [delete]
func (tc *CheckerScheduleController) DeleteCheckerSchedule(c *gin.Context) {
user := middleware.MyUser(c)
scheduleIdStr := c.Param("schedule_id")
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
return
}
// Verify ownership
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
middleware.ErrorResponse(c, http.StatusForbidden, err)
return
}
if err := tc.testScheduleUC.DeleteSchedule(scheduleId); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}

View file

@ -0,0 +1,47 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/model"
)
// DeclareTestScheduleRoutes declares test schedule management routes
func DeclareTestScheduleRoutes(router *gin.RouterGroup, checkerScheduleUC happydns.CheckerScheduleUsecase) {
sc := controller.NewCheckerScheduleController(checkerScheduleUC)
schedulesRoutes := router.Group("/plugins/tests/schedules")
{
schedulesRoutes.GET("", sc.ListCheckerSchedules)
schedulesRoutes.POST("", sc.CreateCheckerSchedule)
scheduleRoutes := schedulesRoutes.Group("/:schedule_id")
{
scheduleRoutes.GET("", sc.GetCheckerSchedule)
scheduleRoutes.PUT("", sc.UpdateCheckerSchedule)
scheduleRoutes.DELETE("", sc.DeleteCheckerSchedule)
}
}
}

View file

@ -108,6 +108,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)
DeclareRecordRoutes(apiAuthRoutes)
DeclareTestScheduleRoutes(apiAuthRoutes, dep.CheckerSchedule)
DeclareUsersRoutes(apiAuthRoutes, dep.User, lc)
DeclareSessionRoutes(apiAuthRoutes, dep.Session)
}

View file

@ -58,6 +58,7 @@ type Usecases struct {
authUser happydns.AuthUserUsecase
checker happydns.CheckerUsecase
checkResult happydns.CheckResultUsecase
checkerSchedule happydns.CheckerScheduleUsecase
domain happydns.DomainUsecase
domainLog happydns.DomainLogUsecase
provider happydns.ProviderUsecase
@ -85,6 +86,7 @@ type App struct {
router *gin.Engine
srv *http.Server
store storage.Storage
checkScheduler happydns.SchedulerUsecase
usecases Usecases
}
@ -102,6 +104,7 @@ func NewApp(cfg *happydns.Options) *App {
}
app.initUsecases()
app.initCaptcha()
app.initCheckScheduler()
app.setupRouter()
return app
@ -120,6 +123,7 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App {
}
app.initUsecases()
app.initCaptcha()
app.initCheckScheduler()
app.setupRouter()
return app
@ -193,6 +197,20 @@ func (app *App) initInsights() {
}
}
func (app *App) initCheckScheduler() {
if app.cfg.DisableScheduler {
// Use a disabled scheduler that returns clear errors
app.checkScheduler = &disabledScheduler{}
return
}
app.checkScheduler = newCheckScheduler(
app.cfg,
app.store,
app.usecases.checker,
)
}
func (app *App) initUsecases() {
sessionService := sessionUC.NewService(app.store)
authUserService := authuserUC.NewAuthUserUsecases(app.cfg, app.mailer, app.store, sessionService)
@ -222,6 +240,7 @@ func (app *App) initUsecases() {
app.usecases.session = sessionService
app.usecases.checker = checkUC.NewCheckerUsecase(app.cfg, app.store)
app.usecases.checkResult = checkresultUC.NewCheckResultUsecase(app.store, app.cfg)
app.usecases.checkerSchedule = checkresultUC.NewCheckScheduleUsecase(app.store, app.cfg)
app.usecases.orchestrator = orchestrator.NewOrchestrator(
domainLogService,
@ -292,6 +311,8 @@ func (app *App) Start() {
go app.insights.Run()
}
go app.checkScheduler.Run()
log.Printf("Public interface listening on %s\n", app.cfg.Bind)
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
@ -317,4 +338,6 @@ func (app *App) Stop() {
if app.failureTracker != nil {
app.failureTracker.Close()
}
app.checkScheduler.Close()
}

View file

@ -0,0 +1,534 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package app
import (
"container/heap"
"context"
"fmt"
"log"
"runtime"
"sync"
"time"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/usecase/checkresult"
"git.happydns.org/happyDomain/model"
)
const (
SchedulerCheckInterval = 1 * time.Minute // How often to check for due tests
SchedulerCleanupInterval = 24 * time.Hour // How often to clean up old executions
CheckExecutionTimeout = 5 * time.Minute // Max time for a single test
MaxRetries = 3 // Max retry attempts for failed tests
)
// Priority levels for test execution queue
const (
PriorityOnDemand = iota // On-demand tests (highest priority)
PriorityOverdue // Overdue scheduled tests
PriorityScheduled // Regular scheduled tests
)
// checkScheduler manages background test execution
type checkScheduler struct {
cfg *happydns.Options
store storage.Storage
checkerUsecase happydns.CheckerUsecase
resultUsecase *checkresult.CheckResultUsecase
scheduleUsecase *checkresult.CheckScheduleUsecase
stop chan struct{} // closed to stop the main Run loop
stopWorkers chan struct{} // closed to stop all workers simultaneously
runNowChan chan *queueItem // on-demand items routed through the main loop
workAvail chan struct{} // non-blocking signals that queue has new work
queue *priorityQueue
activeExecutions map[string]*activeExecution
workers []*worker
mu sync.RWMutex
wg sync.WaitGroup
}
// activeExecution tracks a running test execution
type activeExecution struct {
execution *happydns.CheckExecution
cancel context.CancelFunc
startTime time.Time
}
// queueItem represents a test execution request in the queue
type queueItem struct {
schedule *happydns.CheckerSchedule
execution *happydns.CheckExecution
priority int
queuedAt time.Time
retries int
}
// --- container/heap implementation for priorityQueue ---
// priorityHeap is the underlying heap, ordered by priority then arrival time.
type priorityHeap []*queueItem
func (h priorityHeap) Len() int { return len(h) }
func (h priorityHeap) Less(i, j int) bool {
if h[i].priority != h[j].priority {
return h[i].priority < h[j].priority
}
return h[i].queuedAt.Before(h[j].queuedAt)
}
func (h priorityHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *priorityHeap) Push(x any) { *h = append(*h, x.(*queueItem)) }
func (h *priorityHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
old[n-1] = nil // avoid memory leak
*h = old[:n-1]
return x
}
// priorityQueue is a thread-safe min-heap of queueItems.
type priorityQueue struct {
h priorityHeap
mu sync.Mutex
}
func newPriorityQueue() *priorityQueue {
pq := &priorityQueue{}
heap.Init(&pq.h)
return pq
}
// Push adds an item to the queue.
func (q *priorityQueue) Push(item *queueItem) {
q.mu.Lock()
defer q.mu.Unlock()
heap.Push(&q.h, item)
}
// Pop removes and returns the highest-priority item, or nil if empty.
func (q *priorityQueue) Pop() *queueItem {
q.mu.Lock()
defer q.mu.Unlock()
if q.h.Len() == 0 {
return nil
}
return heap.Pop(&q.h).(*queueItem)
}
// Len returns the queue length.
func (q *priorityQueue) Len() int {
q.mu.Lock()
defer q.mu.Unlock()
return q.h.Len()
}
// worker processes tests from the queue
type worker struct {
id int
scheduler *checkScheduler
}
// disabledScheduler is a no-op implementation used when scheduler is disabled
type disabledScheduler struct{}
func (d *disabledScheduler) Run() {}
func (d *disabledScheduler) Close() {}
// TriggerOnDemandCheck returns an error indicating the scheduler is disabled
func (d *disabledScheduler) TriggerOnDemandCheck(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, userId happydns.Identifier, options happydns.CheckerOptions) (happydns.Identifier, error) {
return happydns.Identifier{}, fmt.Errorf("test scheduler is disabled in configuration")
}
// newCheckScheduler creates a new test scheduler
func newCheckScheduler(
cfg *happydns.Options,
store storage.Storage,
checkerUsecase happydns.CheckerUsecase,
) *checkScheduler {
numWorkers := cfg.TestWorkers
if numWorkers <= 0 {
numWorkers = runtime.NumCPU()
}
scheduler := &checkScheduler{
cfg: cfg,
store: store,
checkerUsecase: checkerUsecase,
resultUsecase: checkresult.NewCheckResultUsecase(store, cfg),
scheduleUsecase: checkresult.NewCheckScheduleUsecase(store, cfg),
stop: make(chan struct{}),
stopWorkers: make(chan struct{}),
runNowChan: make(chan *queueItem, 100),
workAvail: make(chan struct{}, numWorkers),
queue: newPriorityQueue(),
activeExecutions: make(map[string]*activeExecution),
workers: make([]*worker, numWorkers),
}
for i := 0; i < numWorkers; i++ {
scheduler.workers[i] = &worker{
id: i,
scheduler: scheduler,
}
}
return scheduler
}
// enqueue pushes an item to the priority queue and wakes one idle worker.
func (s *checkScheduler) enqueue(item *queueItem) {
s.queue.Push(item)
select {
case s.workAvail <- struct{}{}:
default:
// All workers are already busy or already notified; they will drain
// the queue on their own after finishing the current item.
}
}
// Close stops the scheduler and waits for all workers to finish.
func (s *checkScheduler) Close() {
log.Println("Stopping test scheduler...")
// Unblock the main Run loop.
close(s.stop)
// Unblock all workers simultaneously.
close(s.stopWorkers)
// Cancel all active test executions.
s.mu.Lock()
for _, exec := range s.activeExecutions {
exec.cancel()
}
s.mu.Unlock()
// Wait for all workers to finish their current item.
s.wg.Wait()
log.Println("Check scheduler stopped")
}
// Run starts the scheduler main loop. It must not be called more than once.
func (s *checkScheduler) Run() {
log.Printf("Starting test scheduler with %d workers...\n", len(s.workers))
// Start workers
for _, w := range s.workers {
s.wg.Add(1)
go w.run(&s.wg)
}
// Main scheduling loop
checkTicker := time.NewTicker(SchedulerCheckInterval)
cleanupTicker := time.NewTicker(SchedulerCleanupInterval)
defer checkTicker.Stop()
defer cleanupTicker.Stop()
// Initial check
s.checkSchedules()
for {
select {
case <-checkTicker.C:
s.checkSchedules()
case <-cleanupTicker.C:
s.cleanup()
case item := <-s.runNowChan:
s.enqueue(item)
case <-s.stop:
return
}
}
}
// checkSchedules checks for due tests and queues them
func (s *checkScheduler) checkSchedules() {
dueSchedules, err := s.scheduleUsecase.ListDueSchedules()
if err != nil {
log.Printf("Error listing due schedules: %v\n", err)
return
}
now := time.Now()
for _, schedule := range dueSchedules {
// Determine priority based on how overdue the test is
priority := PriorityScheduled
if schedule.NextRun.Add(schedule.Interval).Before(now) {
priority = PriorityOverdue
}
// Create execution record
execution := &happydns.CheckExecution{
ScheduleId: &schedule.Id,
CheckerName: schedule.CheckerName,
OwnerId: schedule.OwnerId,
TargetType: schedule.TargetType,
TargetId: schedule.TargetId,
Status: happydns.CheckExecutionPending,
StartedAt: time.Now(),
Options: schedule.Options,
}
if err := s.resultUsecase.CreateCheckExecution(execution); err != nil {
log.Printf("Error creating execution for schedule %s: %v\n", schedule.Id.String(), err)
continue
}
s.enqueue(&queueItem{
schedule: schedule,
execution: execution,
priority: priority,
queuedAt: now,
retries: 0,
})
}
// Mark scheduler run
if err := s.store.CheckSchedulerRun(); err != nil {
log.Printf("Error marking scheduler run: %v\n", err)
}
}
// TriggerOnDemandCheck triggers an immediate test execution.
// It creates the execution record synchronously (so the caller gets an ID back)
// and then routes the item through runNowChan so the main loop controls
// all queue insertions.
func (s *checkScheduler) TriggerOnDemandCheck(checkerName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, ownerId happydns.Identifier, options happydns.CheckerOptions) (happydns.Identifier, error) {
schedule := &happydns.CheckerSchedule{
CheckerName: checkerName,
OwnerId: ownerId,
TargetType: targetType,
TargetId: targetId,
Interval: 0, // On-demand, no interval
Enabled: true,
Options: options,
}
execution := &happydns.CheckExecution{
ScheduleId: nil,
CheckerName: checkerName,
OwnerId: ownerId,
TargetType: targetType,
TargetId: targetId,
Status: happydns.CheckExecutionPending,
StartedAt: time.Now(),
Options: options,
}
if err := s.resultUsecase.CreateCheckExecution(execution); err != nil {
return happydns.Identifier{}, err
}
item := &queueItem{
schedule: schedule,
execution: execution,
priority: PriorityOnDemand,
queuedAt: time.Now(),
retries: 0,
}
// Route through the main loop when possible; fall back to direct enqueue
// if the channel is full so that the caller never blocks.
select {
case s.runNowChan <- item:
default:
s.enqueue(item)
}
return execution.Id, nil
}
// cleanup removes old execution records and expired test results
func (s *checkScheduler) cleanup() {
log.Println("Running scheduler cleanup...")
// Delete completed/failed execution records older than 7 days
if err := s.resultUsecase.DeleteCompletedExecutions(7 * 24 * time.Hour); err != nil {
log.Printf("Error cleaning up old executions: %v\n", err)
}
// Delete test results older than the configured retention period
if err := s.resultUsecase.CleanupOldResults(); err != nil {
log.Printf("Error cleaning up old test results: %v\n", err)
}
log.Println("Scheduler cleanup complete")
}
// run is the worker's main loop. It drains the queue eagerly and waits for a
// workAvail signal when idle, rather than sleeping on a fixed timer.
func (w *worker) run(wg *sync.WaitGroup) {
defer wg.Done()
log.Printf("Worker %d started\n", w.id)
for {
// Drain: try to grab work before blocking.
if item := w.scheduler.queue.Pop(); item != nil {
w.executeCheck(item)
continue
}
// Queue is empty; wait for new work or a stop signal.
select {
case <-w.scheduler.workAvail:
// Loop back to attempt a Pop.
case <-w.scheduler.stopWorkers:
log.Printf("Worker %d stopped\n", w.id)
return
}
}
}
// executeCheck runs a checker and stores the result
func (w *worker) executeCheck(item *queueItem) {
ctx, cancel := context.WithTimeout(context.Background(), CheckExecutionTimeout)
defer cancel()
execution := item.execution
schedule := item.schedule
// Mark execution as running
execution.Status = happydns.CheckExecutionRunning
if err := w.scheduler.resultUsecase.UpdateCheckExecution(execution); err != nil {
log.Printf("Worker %d: Error updating execution status: %v\n", w.id, err)
return
}
// Track active execution
w.scheduler.mu.Lock()
w.scheduler.activeExecutions[execution.Id.String()] = &activeExecution{
execution: execution,
cancel: cancel,
startTime: time.Now(),
}
w.scheduler.mu.Unlock()
defer func() {
w.scheduler.mu.Lock()
delete(w.scheduler.activeExecutions, execution.Id.String())
w.scheduler.mu.Unlock()
}()
// Get the checker
checker, err := w.scheduler.checkerUsecase.GetChecker(schedule.CheckerName)
if err != nil {
errMsg := fmt.Sprintf("checker not found: %s - %v", schedule.CheckerName, err)
log.Printf("Worker %d: %s\n", w.id, errMsg)
_ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, errMsg)
return
}
// Prepare metadata
meta := make(map[string]string)
meta["target_type"] = schedule.TargetType.String()
meta["target_id"] = schedule.TargetId.String()
// Run the test
startTime := time.Now()
resultChan := make(chan *happydns.CheckResult, 1)
errorChan := make(chan error, 1)
go func() {
defer func() {
if r := recover(); r != nil {
errorChan <- fmt.Errorf("checker panicked: %v", r)
}
}()
result, err := checker.RunCheck(schedule.Options, meta)
if err != nil {
errorChan <- err
} else {
resultChan <- result
}
}()
// Wait for result or timeout
var checkResult *happydns.CheckResult
var testErr error
select {
case checkResult = <-resultChan:
// Check completed successfully
case testErr = <-errorChan:
// Check returned an error
case <-ctx.Done():
// Timeout
testErr = fmt.Errorf("test execution timeout after %v", CheckExecutionTimeout)
}
duration := time.Since(startTime)
// Store the result
result := &happydns.CheckResult{
CheckerName: schedule.CheckerName,
CheckType: schedule.TargetType,
TargetId: schedule.TargetId,
OwnerId: schedule.OwnerId,
ExecutedAt: time.Now(),
ScheduledCheck: item.execution.ScheduleId != nil,
Options: schedule.Options,
Duration: duration,
}
if testErr != nil {
result.Status = happydns.CheckResultStatusKO
result.StatusLine = "Check execution failed"
result.Error = testErr.Error()
} else if checkResult != nil {
result.Status = checkResult.Status
result.StatusLine = checkResult.StatusLine
result.Report = checkResult.Report
} else {
result.Status = happydns.CheckResultStatusKO
result.StatusLine = "Unknown error"
result.Error = "No result or error returned from check"
}
// Save the result
if err := w.scheduler.resultUsecase.CreateCheckResult(result); err != nil {
log.Printf("Worker %d: Error saving test result: %v\n", w.id, err)
_ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, err.Error())
return
}
// Complete the execution
if err := w.scheduler.resultUsecase.CompleteCheckExecution(execution.Id, result.Id); err != nil {
log.Printf("Worker %d: Error completing execution: %v\n", w.id, err)
return
}
// Update schedule if this was a scheduled test
if item.execution.ScheduleId != nil {
if err := w.scheduler.scheduleUsecase.UpdateScheduleAfterRun(*item.execution.ScheduleId); err != nil {
log.Printf("Worker %d: Error updating schedule: %v\n", w.id, err)
}
}
log.Printf("Worker %d: Completed test %s for target %s (status: %s, duration: %v)\n",
w.id, schedule.CheckerName, schedule.TargetId.String(), result.Status, duration)
}

View file

@ -55,6 +55,7 @@ func ConsolidateConfig() (opts *happydns.Options, err error) {
StorageEngine: "leveldb",
MaxResultsPerCheck: 100,
ResultRetentionDays: 90,
TestWorkers: 2,
}
declareFlags(opts)

View file

@ -0,0 +1,286 @@
// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
package checkresult
import (
"fmt"
"time"
"git.happydns.org/happyDomain/model"
)
const (
// Default check intervals
DefaultUserCheckInterval = 4 * time.Hour // 4 hours for user checks
DefaultDomainCheckInterval = 24 * time.Hour // 24 hours for domain checks
DefaultServiceCheckInterval = 1 * time.Hour // 1 hour for service checks
MinimumCheckInterval = 5 * time.Minute // Minimum interval allowed
)
// CheckScheduleUsecase implements business logic for check schedules
type CheckScheduleUsecase struct {
storage CheckResultStorage
options *happydns.Options
}
// NewCheckScheduleUsecase creates a new check schedule usecase
func NewCheckScheduleUsecase(storage CheckResultStorage, options *happydns.Options) *CheckScheduleUsecase {
return &CheckScheduleUsecase{
storage: storage,
options: options,
}
}
// ListUserSchedules retrieves all schedules for a specific user
func (u *CheckScheduleUsecase) ListUserSchedules(userId happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
return u.storage.ListCheckerSchedulesByUser(userId)
}
// ListSchedulesByTarget retrieves all schedules for a specific target
func (u *CheckScheduleUsecase) ListSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
return u.storage.ListCheckerSchedulesByTarget(targetType, targetId)
}
// GetSchedule retrieves a specific schedule by ID
func (u *CheckScheduleUsecase) GetSchedule(scheduleId happydns.Identifier) (*happydns.CheckerSchedule, error) {
return u.storage.GetCheckerSchedule(scheduleId)
}
// CreateSchedule creates a new check schedule with validation
func (u *CheckScheduleUsecase) CreateSchedule(schedule *happydns.CheckerSchedule) error {
// Validate interval
if schedule.Interval < MinimumCheckInterval {
return fmt.Errorf("check interval must be at least %v", MinimumCheckInterval)
}
// Set default interval if not specified
if schedule.Interval == 0 {
schedule.Interval = u.getDefaultInterval(schedule.TargetType)
}
// Calculate next run time
if schedule.NextRun.IsZero() {
schedule.NextRun = time.Now().Add(schedule.Interval)
}
// Enable by default if not specified
if !schedule.Enabled {
schedule.Enabled = true
}
return u.storage.CreateCheckerSchedule(schedule)
}
// UpdateSchedule updates an existing schedule
func (u *CheckScheduleUsecase) UpdateSchedule(schedule *happydns.CheckerSchedule) error {
// Validate interval
if schedule.Interval < MinimumCheckInterval {
return fmt.Errorf("check interval must be at least %v", MinimumCheckInterval)
}
// Get existing schedule to preserve certain fields
existing, err := u.storage.GetCheckerSchedule(schedule.Id)
if err != nil {
return err
}
// Preserve LastRun if not explicitly changed
if schedule.LastRun == nil {
schedule.LastRun = existing.LastRun
}
// Recalculate next run time if interval changed
if schedule.Interval != existing.Interval {
if schedule.LastRun != nil {
schedule.NextRun = schedule.LastRun.Add(schedule.Interval)
} else {
schedule.NextRun = time.Now().Add(schedule.Interval)
}
}
return u.storage.UpdateCheckerSchedule(schedule)
}
// DeleteSchedule removes a schedule
func (u *CheckScheduleUsecase) DeleteSchedule(scheduleId happydns.Identifier) error {
return u.storage.DeleteCheckerSchedule(scheduleId)
}
// EnableSchedule enables a schedule
func (u *CheckScheduleUsecase) EnableSchedule(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
schedule.Enabled = true
// Reset next run time if it's in the past
if schedule.NextRun.Before(time.Now()) {
schedule.NextRun = time.Now().Add(schedule.Interval)
}
return u.storage.UpdateCheckerSchedule(schedule)
}
// DisableSchedule disables a schedule
func (u *CheckScheduleUsecase) DisableSchedule(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
schedule.Enabled = false
return u.storage.UpdateCheckerSchedule(schedule)
}
// UpdateScheduleAfterRun updates a schedule after it has been executed
func (u *CheckScheduleUsecase) UpdateScheduleAfterRun(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
now := time.Now()
schedule.LastRun = &now
schedule.NextRun = now.Add(schedule.Interval)
return u.storage.UpdateCheckerSchedule(schedule)
}
// ListDueSchedules retrieves all enabled schedules that are due to run
func (u *CheckScheduleUsecase) ListDueSchedules() ([]*happydns.CheckerSchedule, error) {
schedules, err := u.storage.ListEnabledCheckerSchedules()
if err != nil {
return nil, err
}
now := time.Now()
var dueSchedules []*happydns.CheckerSchedule
for _, schedule := range schedules {
if schedule.Enabled && schedule.NextRun.Before(now) {
dueSchedules = append(dueSchedules, schedule)
}
}
return dueSchedules, nil
}
// getDefaultInterval returns the default check interval based on target type
func (u *CheckScheduleUsecase) getDefaultInterval(targetType happydns.CheckScopeType) time.Duration {
switch targetType {
case happydns.CheckScopeUser:
return DefaultUserCheckInterval
case happydns.CheckScopeDomain:
return DefaultDomainCheckInterval
case happydns.CheckScopeService:
return DefaultServiceCheckInterval
default:
return DefaultDomainCheckInterval
}
}
// MergePluginOptions merges plugin options from different scopes
// Priority: schedule options > domain options > user options > global options
func (u *CheckScheduleUsecase) MergeCheckOptions(
globalOpts happydns.CheckerOptions,
userOpts happydns.CheckerOptions,
domainOpts happydns.CheckerOptions,
scheduleOpts happydns.CheckerOptions,
) happydns.CheckerOptions {
merged := make(happydns.CheckerOptions)
// Start with global options
for k, v := range globalOpts {
merged[k] = v
}
// Override with user options
for k, v := range userOpts {
merged[k] = v
}
// Override with domain options
for k, v := range domainOpts {
merged[k] = v
}
// Override with schedule options (highest priority)
for k, v := range scheduleOpts {
merged[k] = v
}
return merged
}
// ValidateScheduleOwnership checks if a user owns a schedule
func (u *CheckScheduleUsecase) ValidateScheduleOwnership(scheduleId happydns.Identifier, userId happydns.Identifier) error {
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
if !schedule.OwnerId.Equals(userId) {
return fmt.Errorf("user does not own this schedule")
}
return nil
}
// CreateDefaultSchedulesForTarget creates default schedules for a new target
func (u *CheckScheduleUsecase) CreateDefaultSchedulesForTarget(
checkerName string,
targetType happydns.CheckScopeType,
targetId happydns.Identifier,
ownerId happydns.Identifier,
enabled bool,
) error {
schedule := &happydns.CheckerSchedule{
CheckerName: checkerName,
OwnerId: ownerId,
TargetType: targetType,
TargetId: targetId,
Interval: u.getDefaultInterval(targetType),
Enabled: enabled,
NextRun: time.Now().Add(u.getDefaultInterval(targetType)),
Options: make(happydns.CheckerOptions),
}
return u.CreateSchedule(schedule)
}
// DeleteSchedulesForTarget removes all schedules for a target
func (u *CheckScheduleUsecase) DeleteSchedulesForTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) error {
schedules, err := u.storage.ListCheckerSchedulesByTarget(targetType, targetId)
if err != nil {
return err
}
for _, schedule := range schedules {
if err := u.storage.DeleteCheckerSchedule(schedule.Id); err != nil {
return err
}
}
return nil
}

View file

@ -27,6 +27,8 @@ import (
// SchedulerUsecase defines the interface for triggering on-demand checks
type SchedulerUsecase interface {
Run()
Close()
TriggerOnDemandCheck(checkerName string, targetType CheckScopeType, targetID Identifier, userID Identifier, options CheckerOptions) (Identifier, error)
}
@ -63,6 +65,30 @@ type CheckerSchedule struct {
Options CheckerOptions `json:"options,omitempty"`
}
// SchedulerStatus holds a snapshot of the scheduler state for monitoring
type SchedulerStatus struct {
// ConfigEnabled indicates if the scheduler is enabled in the configuration file
ConfigEnabled bool `json:"config_enabled"`
// RuntimeEnabled indicates if the scheduler is currently enabled at runtime
RuntimeEnabled bool `json:"runtime_enabled"`
// Running indicates if the scheduler goroutine is currently running
Running bool `json:"running"`
// WorkerCount is the number of worker goroutines
WorkerCount int `json:"worker_count"`
// QueueSize is the number of items currently waiting in the execution queue
QueueSize int `json:"queue_size"`
// ActiveCount is the number of checks currently being executed
ActiveCount int `json:"active_count"`
// NextSchedules contains the upcoming scheduled checks sorted by next run time
NextSchedules []*CheckerSchedule `json:"next_schedules"`
}
// CheckerScheduleUsecase defines business logic for check schedules
type CheckerScheduleUsecase interface {
// ListUserSchedules retrieves all schedules for a specific user

View file

@ -107,6 +107,12 @@ type Options struct {
// ResultRetentionDays is how long to keep test results before cleanup
ResultRetentionDays int
// TestWorkers is the number of concurrent test executions allowed
TestWorkers int
// DisableScheduler disables the background test scheduler
DisableScheduler bool
}
// GetBaseURL returns the full url to the absolute ExternalURL, including BaseURL.