1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-15 18:40:07 +00:00

fix: run jobs at interval instead of specific time (#585)

This commit is contained in:
Alessandro (Ale) Segala
2025-05-29 08:15:35 -07:00
committed by GitHub
parent 3d402fc0ca
commit 6d6dc6646a
7 changed files with 105 additions and 46 deletions

View File

@@ -9,6 +9,7 @@ import (
"time" "time"
backoff "github.com/cenkalti/backoff/v5" backoff "github.com/cenkalti/backoff/v5"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
@@ -22,11 +23,12 @@ func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service
return nil return nil
} }
// Send every 24 hours
jobs := &AnalyticsJob{ jobs := &AnalyticsJob{
appConfig: appConfig, appConfig: appConfig,
httpClient: httpClient, httpClient: httpClient,
} }
return s.registerJob(ctx, "SendHeartbeat", "0 0 * * *", jobs.sendHeartbeat, true) return s.registerJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
} }
type AnalyticsJob struct { type AnalyticsJob struct {

View File

@@ -2,7 +2,10 @@ package job
import ( import (
"context" "context"
"log" "fmt"
"log/slog"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
) )
@@ -18,7 +21,8 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
appConfigService: appConfigService, appConfigService: appConfigService,
} }
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", "0 0 * * *", jobs.checkAndNotifyExpiringApiKeys, false) // Send every day at midnight
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, false)
} }
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error { func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
@@ -29,16 +33,16 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7) apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7)
if err != nil { if err != nil {
log.Printf("Failed to list expiring API keys: %v", err) return fmt.Errorf("failed to list expiring API keys: %w", err)
return err
} }
for _, key := range apiKeys { for _, key := range apiKeys {
if key.User.Email == "" { if key.User.Email == "" {
continue continue
} }
if err := j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key); err != nil { err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
log.Printf("Failed to send email for key %s: %v", key.ID, err) if err != nil {
slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.String("key", key.ID), slog.Any("error", err))
} }
} }
return nil return nil

View File

@@ -3,8 +3,11 @@ package job
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log/slog"
"time" "time"
"github.com/go-co-op/gocron/v2"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
@@ -14,12 +17,14 @@ import (
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error { func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
jobs := &DbCleanupJobs{db: db} jobs := &DbCleanupJobs{db: db}
// Run every 24 hours (but with some jitter so they don't run at the exact same time), and now
def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute)
return errors.Join( return errors.Join(
s.registerJob(ctx, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions, false), s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
s.registerJob(ctx, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens, false), s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes, false), s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.registerJob(ctx, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens, false), s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.registerJob(ctx, "ClearAuditLogs", "0 3 * * *", jobs.clearAuditLogs, false), s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
) )
} }
@@ -29,40 +34,70 @@ type DbCleanupJobs struct {
// ClearWebauthnSessions deletes WebAuthn sessions that have expired // ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error { func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error {
return j.db. st := j.db.
WithContext(ctx). WithContext(ctx).
Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())). Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now()))
Error if st.Error != nil {
return fmt.Errorf("failed to clean expired WebAuthn sessions: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired WebAuthn sessions", slog.Int64("count", st.RowsAffected))
return nil
} }
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired // ClearOneTimeAccessTokens deletes one-time access tokens that have expired
func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error { func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
return j.db. st := j.db.
WithContext(ctx). WithContext(ctx).
Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())). Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
Error if st.Error != nil {
return fmt.Errorf("failed to clean expired one-time access tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired one-time access tokens", slog.Int64("count", st.RowsAffected))
return nil
} }
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired // ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error { func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
return j.db. st := j.db.
WithContext(ctx). WithContext(ctx).
Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())). Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now()))
Error if st.Error != nil {
return fmt.Errorf("failed to clean expired OIDC authorization codes: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired OIDC authorization codes", slog.Int64("count", st.RowsAffected))
return nil
} }
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired // ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error { func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
return j.db. st := j.db.
WithContext(ctx). WithContext(ctx).
Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now())). Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
Error if st.Error != nil {
return fmt.Errorf("failed to clean expired OIDC refresh tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired OIDC refresh tokens", slog.Int64("count", st.RowsAffected))
return nil
} }
// ClearAuditLogs deletes audit logs older than 90 days // ClearAuditLogs deletes audit logs older than 90 days
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error { func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
return j.db. st := j.db.
WithContext(ctx). WithContext(ctx).
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))). Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90)))
Error if st.Error != nil {
return fmt.Errorf("failed to delete old audit logs: %w", st.Error)
}
slog.InfoContext(ctx, "Deleted old audit logs", slog.Int64("count", st.RowsAffected))
return nil
} }

View File

@@ -3,11 +3,13 @@ package job
import ( import (
"context" "context"
"fmt" "fmt"
"log" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/go-co-op/gocron/v2"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
@@ -17,7 +19,8 @@ import (
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) error { func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) error {
jobs := &FileCleanupJobs{db: db} jobs := &FileCleanupJobs{db: db}
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", "0 2 * * 0", jobs.clearUnusedDefaultProfilePictures, false) // Run every 24 hours
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
} }
type FileCleanupJobs struct { type FileCleanupJobs struct {
@@ -64,13 +67,13 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
if _, ok := initialsInUse[initials]; !ok { if _, ok := initialsInUse[initials]; !ok {
filePath := filepath.Join(defaultPicturesDir, filename) filePath := filepath.Join(defaultPicturesDir, filename)
if err := os.Remove(filePath); err != nil { if err := os.Remove(filePath); err != nil {
log.Printf("Failed to delete unused default profile picture %s: %v", filePath, err) slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err))
} else { } else {
filesDeleted++ filesDeleted++
} }
} }
} }
log.Printf("Deleted %d unused default profile pictures", filesDeleted) slog.Info("Done deleting unused default profile pictures", slog.Int("count", filesDeleted))
return nil return nil
} }

View File

@@ -2,6 +2,9 @@ package job
import ( import (
"context" "context"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
) )
@@ -19,8 +22,8 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService} jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
// Register the job to run every day, at 5 minutes past midnight // Run every 24 hours (and right away)
return s.registerJob(ctx, "UpdateGeoLiteDB", "5 * */1 * *", jobs.updateGoeLiteDB, true) return s.registerJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
} }
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error { func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {

View File

@@ -2,6 +2,9 @@ package job
import ( import (
"context" "context"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
) )
@@ -15,7 +18,7 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService} jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
// Register the job to run every hour // Register the job to run every hour
return s.registerJob(ctx, "SyncLdap", "0 * * * *", jobs.syncLdap, true) return s.registerJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
} }
func (j *LdapJobs) syncLdap(ctx context.Context) error { func (j *LdapJobs) syncLdap(ctx context.Context) error {

View File

@@ -3,7 +3,7 @@ package job
import ( import (
"context" "context"
"fmt" "fmt"
"log" "log/slog"
"github.com/go-co-op/gocron/v2" "github.com/go-co-op/gocron/v2"
"github.com/google/uuid" "github.com/google/uuid"
@@ -27,7 +27,7 @@ func NewScheduler() (*Scheduler, error) {
// Run the scheduler. // Run the scheduler.
// This function blocks until the context is canceled. // This function blocks until the context is canceled.
func (s *Scheduler) Run(ctx context.Context) error { func (s *Scheduler) Run(ctx context.Context) error {
log.Println("Starting job scheduler") slog.Info("Starting job scheduler")
s.scheduler.Start() s.scheduler.Start()
// Block until context is canceled // Block until context is canceled
@@ -35,23 +35,36 @@ func (s *Scheduler) Run(ctx context.Context) error {
err := s.scheduler.Shutdown() err := s.scheduler.Shutdown()
if err != nil { if err != nil {
log.Printf("[WARN] Error shutting down job scheduler: %v", err) slog.Error("Error shutting down job scheduler", slog.Any("error", err))
} else { } else {
log.Println("Job scheduler shut down") slog.Info("Job scheduler shut down")
} }
return nil return nil
} }
func (s *Scheduler) registerJob(ctx context.Context, name string, interval string, job func(ctx context.Context) error, runImmediately bool) error { func (s *Scheduler) registerJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool) error {
jobOptions := []gocron.JobOption{ jobOptions := []gocron.JobOption{
gocron.WithContext(ctx), gocron.WithContext(ctx),
gocron.WithEventListeners( gocron.WithEventListeners(
gocron.BeforeJobRuns(func(jobID uuid.UUID, jobName string) {
slog.Info("Starting job",
slog.String("name", name),
slog.String("id", jobID.String()),
)
}),
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) { gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
log.Printf("Job %q run successfully", name) slog.Info("Job run successfully",
slog.String("name", name),
slog.String("id", jobID.String()),
)
}), }),
gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) { gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
log.Printf("Job %q failed with error: %v", name, err) slog.Error("Job failed with error",
slog.String("name", name),
slog.String("id", jobID.String()),
slog.Any("error", err),
)
}), }),
), ),
} }
@@ -60,11 +73,7 @@ func (s *Scheduler) registerJob(ctx context.Context, name string, interval strin
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately())) jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
} }
_, err := s.scheduler.NewJob( _, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
gocron.CronJob(interval, false),
gocron.NewTask(job),
jobOptions...,
)
if err != nil { if err != nil {
return fmt.Errorf("failed to register job %q: %w", name, err) return fmt.Errorf("failed to register job %q: %w", name, err)