feat: media duplicate purge, part 2 of 3: the automatic purge scheduler (Go only)
This commit is contained in:
1 parent
e82d0ce254
commit
1a55a39425
18 files changed
+1389
-39
No files matched your search
@@ -15,6 +15,7 @@ import (
|
||||
"RenewCMS/internal/infrastructure/markdown"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/scheduler"
|
||||
"RenewCMS/internal/infrastructure/update"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"crypto/ed25519"
|
||||
@@ -36,6 +37,7 @@ type UseCases struct {
|
||||
apiKeyAuthOnce sync.Once
|
||||
apiKeyAuthMiddleware func(http.Handler) http.Handler
|
||||
Token *jwtauth.JWTAuth
|
||||
PurgeScheduler *scheduler.Runner
|
||||
CreateArticleUseCase *useCases.CreateArticleUseCase
|
||||
GetArticleUseCase *useCases.GetArticleUseCase
|
||||
ListArticlesUseCase *useCases.ListArticlesUseCase
|
||||
@@ -175,7 +177,11 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||
keyGenerator := hashing.NewSHA256KeyGenerator()
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo)
|
||||
|
||||
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default())
|
||||
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge)
|
||||
|
||||
return &UseCases{
|
||||
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
|
||||
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
||||
@@ -207,7 +213,7 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
|
||||
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
|
||||
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||
RunPurgeUseCase: useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default()),
|
||||
RunPurgeUseCase: runPurge,
|
||||
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
|
||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
|
||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now),
|
||||
|
||||
+12
-3
@@ -81,7 +81,7 @@ func run() error {
|
||||
validator.InitValidator()
|
||||
|
||||
server := newServer(api.InitRoutes(container, settings), settings)
|
||||
return serveUntilStopped(server, settings, container.RecordRunningInfoUseCase.Record)
|
||||
return serveUntilStopped(server, settings, container.RecordRunningInfoUseCase.Record, container.PurgeScheduler.Run)
|
||||
}
|
||||
|
||||
func initEnvVariables() {
|
||||
@@ -132,9 +132,18 @@ func newServer(handler http.Handler, settings config.Settings) *http.Server {
|
||||
}
|
||||
}
|
||||
|
||||
func serveUntilStopped(server *http.Server, settings config.Settings, recordRunning func() error) error {
|
||||
func serveUntilStopped(server *http.Server, settings config.Settings, recordRunning func() error, runScheduler func(context.Context)) error {
|
||||
stopSignal, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
schedulerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(schedulerDone)
|
||||
runScheduler(stopSignal)
|
||||
}()
|
||||
defer func() {
|
||||
stop()
|
||||
<-schedulerDone
|
||||
}()
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
go listen(server, serverErr, settings, recordRunning)
|
||||
|
||||
@@ -32,10 +32,18 @@ Nothing under `internal/` imports `api/`.
|
||||
|
||||
### Media purge
|
||||
|
||||
`internal/domain/purge` holds `Settings` (automatic purge on or off, interval of 1 to 365 days, `last_purge_*` history, `enabled_at`), the `Repository` interface (`Get` returns the defaults when nothing was saved, `Save`) and the sentinels `ErrInvalidPurgeInterval`, `ErrPurgeConflict` (stale fingerprint) and `ErrPurgeBusy` (a run is already in progress). `Settings.NextPurgeAt` is `max(last_purge_at, enabled_at)` plus the interval when automatic purge is on, and null otherwise. The settings live in a single-row `purge_settings` table (`models.PurgeSettings`, `mappers.PurgeSettings*`, `persistence.PurgeSettingsRepository`), created by `AutoMigrate`.
|
||||
`internal/domain/purge` holds `Settings` (automatic purge on or off, interval of 1 to 365 days, `last_purge_*` history, `enabled_at`), the `Repository` interface (`Get` returns the defaults when nothing was saved, `Save`, and `SaveLastRun`, which writes only the `last_purge_*` columns so a settings change made during a run is never overwritten) and the sentinels `ErrInvalidPurgeInterval`, `ErrPurgeConflict` (stale fingerprint) and `ErrPurgeBusy` (a run is already in progress). `Settings.NextPurgeAt` is `max(last_purge_at, enabled_at)` plus the interval when automatic purge is on, and null otherwise. The settings live in a single-row `purge_settings` table (`models.PurgeSettings`, `mappers.PurgeSettings*`, `persistence.PurgeSettingsRepository`), created by `AutoMigrate`.
|
||||
|
||||
The use cases `GetPurgeSettings`, `UpdatePurgeSettings`, `PreviewPurge` and `RunPurge` depend only on domain interfaces, an injected clock and an injected `*log.Logger`, so a scheduler can call them. `PreviewPurge` and `RunPurge` share one plan (`purgePlan.go`): a duplicate group (same non-empty sha256) is planned only when a copy is used in an article, the most used copy is kept (ties: oldest `created_at`, then lowest id) and every other copy is merged into it. The fingerprint is a SHA-256 over the planned groups. `RunPurge` takes a non-blocking in-process lock (`ErrPurgeBusy` for a concurrent call), refuses a non-matching fingerprint with `ErrPurgeConflict`, merges group by group with `MergeMedia` (a failing group is counted and skipped), unions the `UpdatedArticleIDs` of the fully merged groups, and records `last_purge_*` after every run.
|
||||
|
||||
#### Automatic purge scheduler
|
||||
|
||||
`PurgeSchedulerUseCase.Tick(now)` returns the `purge.RunResult` of the run, whether it ran and an error. It reads the purge settings on every call and runs `RunPurge.Run(nil)` (no fingerprint) when `Settings.NextPurgeAt()` is set and `now` is not before it. It never writes settings: only `RunPurge` records `last_purge_*`, through `SaveLastRun`, which moves the anchor forward even when nothing was purged. The runner in `internal/infrastructure/scheduler` calls `Tick` and logs one line with `groups_purged`, `merged`, `articles_updated` and `failed_groups` when a run happened. It calls `Tick` once after a startup delay of 1 minute, which catches up a run missed during downtime in a single purge, then once every hour. `api/dependecyInjection.go` builds it and `cmd/server/main.go` starts it with the signal context of the server. Changes to the settings apply on the next tick, with no restart.
|
||||
|
||||
- Overlap guard: a manual run holds the `RunPurge` lock, so `Tick` returns `ErrPurgeBusy` at once. The runner treats it as a skip, logs nothing and re-evaluates at the next tick.
|
||||
- Failures: any other error is logged and the loop keeps going. There is no retry inside a tick; the next hourly tick evaluates the schedule again.
|
||||
- Shutdown: on SIGINT or SIGTERM the runner stops taking ticks and waits, with no time limit, for a `Tick` in progress. `main` waits for the runner to return before the database is closed.
|
||||
|
||||
`middleware.RequireOwnerOrAdmin` lets only the roles owner and admin through (403 otherwise, including an empty role). The media router mounts these routes after the verified chain and before the `/{id}` routes:
|
||||
|
||||
| Route | Purpose |
|
||||
|
||||
@@ -33,6 +33,8 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|
||||
| `RENEWCMS_CONTAINER` | `false` | Set to `true` (case-insensitive; any other value means `false`) when the app runs in a container. Settings then tells the owner to update by pulling a new image instead of offering browser updates, and `can_apply` is always `false`. The Dockerfile and `docker-compose.yml` set it to `true`. Update checks still work. |
|
||||
| `UPDATE_FEED_URL` | `https://git.floriansylvain.fr` | Base URL of the Gitea server that publishes RenewCMS releases. The Settings page asks `{UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases` when the owner checks for updates. The last result is kept in `update-status.json`, next to `DB_FILE`. |
|
||||
|
||||
Automatic media purge has no environment variable. Its schedule (on or off, interval, `enabled_at`, `last_purge_at`) lives in the purge settings row of the database, is changed from the Settings page or `PATCH /v1/media/purge-settings`, and is read again at every hourly tick.
|
||||
|
||||
Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
|
||||
|
||||
For example, `WorkingDirectory=/var/lib/renewcms` with `DB_FILE=./data/renewcms.db` puts the database in `/var/lib/renewcms/data`. Browser-update `--data-dir` must name that directory, because the app reads helper status and update records beside the database. It is not necessarily `WorkingDirectory` or `ReadWritePaths`. Do not change an existing `DB_FILE` merely to match the helper: that could open a different, empty database. See [browser-update setup](deployment.md#browser-updates).
|
||||
|
||||
@@ -8,7 +8,7 @@ Feature: Owners and admins can merge duplicate images in use, with persisted pur
|
||||
Given internal/domain/purge defines the entity Settings with AutoPurgeEnabled (default false),
|
||||
AutoPurgeIntervalDays (default 30, valid 1 to 365), LastPurgeAt, LastPurgeMerged,
|
||||
LastPurgeArticlesUpdated and EnabledAt (unset or 0 by default), plus a Repository
|
||||
interface with Get and Save, and the sentinels ErrInvalidPurgeInterval, ErrPurgeConflict
|
||||
interface with Get, Save and SaveLastRun, and the sentinels ErrInvalidPurgeInterval, ErrPurgeConflict
|
||||
(the library changed since the fingerprint) and ErrPurgeBusy (a run is already in progress),
|
||||
all asserted with errors.Is
|
||||
And Repository.Get returns the defaults when nothing was ever saved (a single-row table)
|
||||
@@ -389,7 +389,7 @@ Feature: Owners and admins can merge duplicate images in use, with persisted pur
|
||||
And no last_purge_* values are saved
|
||||
|
||||
Scenario: A failing save of the run result is a logged 500 and the merges stay
|
||||
Given the purge settings repository's Save fails after the merges succeeded
|
||||
Given the purge settings repository's SaveLastRun fails after the merges succeeded
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 500 and the body is "Internal server error"
|
||||
And the merges that already happened stay done
|
||||
@@ -1,4 +1,4 @@
|
||||
# QA procedure: media-purge
|
||||
# QA procedure: media_purge
|
||||
|
||||
Package: `test` (black-box). New files `test/media_purge_http_test.go` and `test/media_purge_test.go` (use case level). Fake type names must be unique in the package. Go only: no SPA, no scheduler, no env var.
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
Feature: Duplicate images are purged automatically on the configured schedule
|
||||
|
||||
As a RenewCMS owner or admin
|
||||
I want the duplicate purge to run by itself every few days once I turn it on
|
||||
So that I do not have to remember to purge, and the Settings "Last purge" line stays current
|
||||
|
||||
Background:
|
||||
Given part 1 provides purge.Settings, purge.Repository (Get, Save and SaveLastRun) and RunPurgeUseCase.Run(fingerprint *string)
|
||||
And a new use case PurgeSchedulerUseCase in internal/infrastructure/useCases depends only on purge.Repository
|
||||
and a RunPurge port, takes no HTTP types and imports no persistence, mailer, models, gorm, net/http, chi or api
|
||||
And PurgeSchedulerUseCase.Tick(now time.Time) (result purge.RunResult, ran bool, err error) reads the settings on every call,
|
||||
where result carries groups purged, copies merged and articles updated and is empty when ran is false
|
||||
And a run is due when auto_purge_enabled is true, enabled_at is set and now is not before
|
||||
Settings.NextPurgeAt(), that is max(last_purge_at, enabled_at) + auto_purge_interval_days days
|
||||
And a scheduled run is RunPurge.Run(nil): no fingerprint, no HTTP, no user-visible message
|
||||
And a run that finds nothing to purge still records last_purge_at with merged 0 and articles_updated 0,
|
||||
so the anchor moves forward (this is RunPurge's existing behaviour)
|
||||
And the scheduler never writes settings itself: only RunPurge records last_purge_*,
|
||||
through Repository.SaveLastRun, which writes only the last_purge_* fields and never the whole settings row
|
||||
And there is no env var: the schedule (auto_purge_enabled, auto_purge_interval_days, enabled_at,
|
||||
last_purge_at) lives in the purge settings row, and is read again on every tick
|
||||
And a runner in the new package internal/infrastructure/scheduler calls Tick on a ticker every 1 hour,
|
||||
runs one Tick after a startup delay of 1 minute, and stops when its context is cancelled
|
||||
And the runner takes its clock, its tick source and its startup delay by injection, so tests use a fake clock
|
||||
and hand-fed channels, with no real sleeping and no real time.Ticker
|
||||
And api/dependecyInjection.go builds the runner and cmd/server/main.go only starts it
|
||||
And the runner uses the *log.Logger already used by RunPurge (log.Default() in the container)
|
||||
|
||||
Rule: When a run is due
|
||||
|
||||
Background:
|
||||
Given the fake clock reads 2026-10-05T09:00:00Z
|
||||
|
||||
Scenario: Automatic purge off never runs, whatever the dates are
|
||||
Given auto_purge_enabled is false, interval 1, enabled_at 2020-01-01T00:00:00Z and last_purge_at 2020-01-02T00:00:00Z
|
||||
When Tick is called with the fake clock's time
|
||||
Then ran is false and err is nil
|
||||
And RunPurge was not called and Save was not called
|
||||
|
||||
Scenario: The defaults never run
|
||||
Given nothing was ever saved, so the settings are the defaults
|
||||
When Tick is called
|
||||
Then ran is false and RunPurge was not called
|
||||
|
||||
Scenario: On, never ran, not due yet
|
||||
Given auto_purge_enabled is true, interval 30, enabled_at 2026-09-20T09:00:00Z and no last_purge_at
|
||||
When Tick is called
|
||||
Then ran is false and RunPurge was not called
|
||||
|
||||
Scenario: On, never ran, due since enabled_at plus the interval
|
||||
Given auto_purge_enabled is true, interval 10, enabled_at 2026-09-25T09:00:00Z and no last_purge_at
|
||||
When Tick is called
|
||||
Then RunPurge is called once with no fingerprint and ran is true
|
||||
|
||||
Scenario Outline: The anchor is the later of last_purge_at and enabled_at
|
||||
Given auto_purge_enabled is true and interval 10
|
||||
And enabled_at is <enabled_at> and last_purge_at is <last_purge_at>
|
||||
When Tick is called at 2026-10-05T09:00:00Z
|
||||
Then RunPurge is called <calls> time(s)
|
||||
|
||||
Examples:
|
||||
| enabled_at | last_purge_at | calls | why |
|
||||
| 2026-09-01T00:00:00Z | 2026-09-20T00:00:00Z | 1 | last purge + 10 days = 2026-09-30, due |
|
||||
| 2026-09-01T00:00:00Z | 2026-09-28T00:00:00Z | 0 | last purge + 10 days = 2026-10-08, not due |
|
||||
| 2026-09-28T00:00:00Z | 2026-09-01T00:00:00Z | 0 | enabled_at is later, 2026-10-08, not due |
|
||||
| 2026-09-20T00:00:00Z | 2026-09-01T00:00:00Z | 1 | enabled_at is later, 2026-09-30, due |
|
||||
| 2026-09-25T09:00:00Z | none | 1 | exactly now is due (2026-10-05T09:00:00Z) |
|
||||
| 2026-09-25T09:00:01Z | none | 0 | one second before due |
|
||||
|
||||
Scenario: Due exactly at the boundary, not before
|
||||
Given the next purge is 2026-10-05T09:00:00Z
|
||||
When Tick is called at 2026-10-05T08:59:59Z
|
||||
Then RunPurge is not called
|
||||
When Tick is called at 2026-10-05T09:00:00Z
|
||||
Then RunPurge is called once
|
||||
|
||||
Scenario: A run advances the anchor, so the next tick is not due
|
||||
Given a due run with RunPurge backed by the fake settings repository
|
||||
When Tick is called twice with the same time
|
||||
Then RunPurge is called once
|
||||
And last_purge_at equals the fake clock's time
|
||||
|
||||
Scenario: A due run that finds nothing to purge still moves the clock forward
|
||||
Given a due schedule and a library with no planned group
|
||||
When Tick is called
|
||||
Then the stored last_purge_at is the clock's time, last_purge_merged is 0 and last_purge_articles_updated is 0
|
||||
And a second Tick at the same time does not run
|
||||
And a Tick after the interval has elapsed runs again
|
||||
|
||||
Scenario: A run keeps auto_purge_enabled, the interval and enabled_at
|
||||
Given a due schedule
|
||||
When Tick runs
|
||||
Then auto_purge_enabled, auto_purge_interval_days and enabled_at are unchanged in the stored settings
|
||||
|
||||
Scenario: Enabled with no enabled_at is not due
|
||||
Given auto_purge_enabled is true and enabled_at is unset
|
||||
When Tick is called
|
||||
Then RunPurge is not called and the scheduler does not stamp enabled_at itself
|
||||
|
||||
Rule: Settings changes take effect on the next tick, with no restart
|
||||
|
||||
Background:
|
||||
Given the fake clock reads 2026-10-05T09:00:00Z
|
||||
|
||||
Scenario: Shortening the interval makes a run due on the next tick
|
||||
Given auto_purge_enabled is true since 2026-09-25T09:00:00Z with interval 30 and no purge yet
|
||||
And Tick did not run
|
||||
When UpdatePurgeSettings sets auto_purge_interval_days to 7
|
||||
And Tick is called again
|
||||
Then RunPurge is called once
|
||||
|
||||
Scenario: Lengthening the interval postpones the run
|
||||
Given auto_purge_enabled is true since 2026-09-20T09:00:00Z with interval 10 and no purge yet, so a run is due
|
||||
When UpdatePurgeSettings sets auto_purge_interval_days to 30
|
||||
And Tick is called
|
||||
Then RunPurge is not called
|
||||
|
||||
Scenario: Turning it off stops runs
|
||||
Given a due schedule
|
||||
When UpdatePurgeSettings sets auto_purge_enabled to false
|
||||
And Tick is called
|
||||
Then RunPurge is not called
|
||||
|
||||
Scenario: Turning it on resets the anchor to the moment of the switch
|
||||
Given auto_purge_enabled was turned off, last_purge_at is 2026-09-01T00:00:00Z, the interval is 30 and the clock reads 2026-10-05T09:00:00Z
|
||||
When UpdatePurgeSettings sets auto_purge_enabled to true
|
||||
And Tick is called at once
|
||||
Then RunPurge is not called, because enabled_at is now
|
||||
When Tick is called at 2026-11-04T09:00:00Z
|
||||
Then RunPurge is called once
|
||||
|
||||
Scenario: The scheduler reads the settings at every tick
|
||||
When Tick is called three times with different stored settings
|
||||
Then Repository.Get is called three times and no value is cached between ticks
|
||||
|
||||
Rule: Failures never crash the loop and are retried at the next tick
|
||||
|
||||
Background:
|
||||
Given a due schedule and the fake clock reads 2026-10-05T09:00:00Z
|
||||
|
||||
Scenario: Reading the settings fails
|
||||
Given the purge settings repository's next Get fails
|
||||
When Tick is called
|
||||
Then ran is false and err is the repository error
|
||||
And RunPurge was not called
|
||||
And the next Tick, with Get working again, runs the purge
|
||||
|
||||
Scenario: RunPurge returns an error
|
||||
Given RunPurge fails, for example ListDuplicates fails
|
||||
When Tick is called
|
||||
Then err is that error and last_purge_at is unchanged
|
||||
And RunPurge was called once, not retried inside the same Tick
|
||||
And the next Tick, with the failure cleared, runs the purge
|
||||
|
||||
Scenario: Saving the run result fails after the merges succeeded
|
||||
Given the purge settings repository's SaveLastRun fails
|
||||
When Tick is called
|
||||
Then err is returned and the merges stay done
|
||||
And the next Tick plans again from current data and converges
|
||||
|
||||
Scenario: A run that partially fails still records what it merged
|
||||
Given group A merges and group B's UpdateBody fails
|
||||
When Tick is called
|
||||
Then ran is true and err is nil
|
||||
And last_purge_at is set and last_purge_merged and last_purge_articles_updated count only group A
|
||||
And group B is planned again by the next run
|
||||
|
||||
Scenario: A settings change made during a run survives
|
||||
Given a run is in progress and about to record its result
|
||||
When UpdatePurgeSettings turns auto_purge_enabled off and sets the interval to 5 meanwhile
|
||||
And the run completes
|
||||
Then the stored auto_purge_enabled is false and the interval is 5
|
||||
And last_purge_at, last_purge_merged and last_purge_articles_updated hold the run's values
|
||||
|
||||
Scenario: A manual run is in progress
|
||||
Given a manual POST /v1/media/purge holds the RunPurge guard
|
||||
When Tick is called with a due schedule
|
||||
Then the scheduler does not queue or wait: it returns at once with errors.Is(err, purge.ErrPurgeBusy)
|
||||
And nothing is saved by the scheduler
|
||||
And the runner treats it as a skip, not a failure, and the next tick re-evaluates
|
||||
When the manual run finishes and records last_purge_at
|
||||
Then the next Tick finds the schedule not due and does not run
|
||||
|
||||
Scenario: A failing tick does not stop the runner
|
||||
Given the runner is running with a hand-fed tick channel
|
||||
And the first Tick returns an error and the second returns ran true
|
||||
When two ticks are fed
|
||||
Then the error is written to the injected logger
|
||||
And the runner is still running and the second Tick was executed
|
||||
And no Tick is called between the two fed ticks (no tight retry loop)
|
||||
|
||||
Scenario: A completed run logs one line with the counts
|
||||
Given Tick returns ran true with groups purged 3, merged 5, articles updated 2 and failed groups 1
|
||||
When the runner receives the tick
|
||||
Then exactly one line is logged: "scheduler: automatic purge completed: groups_purged=3 merged=5 articles_updated=2 failed_groups=1"
|
||||
And a run that found nothing to purge logs the same line with every count at 0
|
||||
|
||||
Scenario: A due Tick returns what the run did
|
||||
Given a due schedule and one planned group
|
||||
When Tick is called
|
||||
Then ran is true and the result holds the run's groups purged, merged and articles updated
|
||||
And a Tick that is not due returns an empty result
|
||||
|
||||
Scenario: A busy skip is not logged as an error
|
||||
Given Tick returns ErrPurgeBusy
|
||||
When the runner receives the tick
|
||||
Then it logs no error line and keeps waiting for the next tick
|
||||
|
||||
Rule: The runner starts, ticks and stops with the server
|
||||
|
||||
Scenario: One Tick shortly after startup
|
||||
Given a runner with a hand-fed startup signal standing in for the 1 minute delay
|
||||
When the startup signal fires
|
||||
Then Tick is called once with the fake clock's time
|
||||
And no Tick is called before the signal
|
||||
|
||||
Scenario: A due run is caught up at startup after downtime
|
||||
Given the server was down for 40 days, the interval is 30 and last_purge_at is 40 days old
|
||||
When the runner's startup Tick fires
|
||||
Then RunPurge is called once, and only once however many ticks were missed
|
||||
|
||||
Scenario: A Tick on each hourly tick
|
||||
Given the runner is running
|
||||
When three hourly ticks are fed
|
||||
Then Tick is called three times, each with the fake clock's time at that tick
|
||||
|
||||
Scenario: Cancelling the context stops the runner
|
||||
Given the runner is idle
|
||||
When its context is cancelled
|
||||
Then Run returns promptly
|
||||
And no further Tick is called even if a tick is fed after cancel
|
||||
|
||||
Scenario: Shutdown waits for a run in progress without a time limit
|
||||
Given Tick is blocked inside RunPurge (fake image repository blocked on a channel)
|
||||
When the context is cancelled
|
||||
Then Run does not return yet
|
||||
When the blocked run is released and completes
|
||||
Then Run returns after the run has recorded last_purge_at
|
||||
And the database is closed only after Run has returned
|
||||
|
||||
Scenario: Cancelled before the startup signal
|
||||
Given the context is cancelled before the startup signal
|
||||
Then Tick is never called and Run returns
|
||||
|
||||
Rule: The Settings page shows the result (HTTP observable, no SPA change)
|
||||
|
||||
Scenario: A scheduled run updates the Last purge values served by the settings endpoint
|
||||
Given an owner, automatic purge on with interval 7, enabled_at 2026-09-20T09:00:00Z and a planned group
|
||||
And the fake clock reads 2026-10-05T09:00:00Z
|
||||
When Tick runs
|
||||
And the client sends GET /v1/media/purge-settings with a valid cookie of the owner
|
||||
Then "last_purge_at" is "2026-10-05T09:00:00Z"
|
||||
And "last_purge_merged" and "last_purge_articles_updated" hold the run's counts
|
||||
And "next_purge_at" is "2026-10-12T09:00:00Z"
|
||||
And "auto_purge_enabled", "auto_purge_interval_days" and "enabled_at" are unchanged
|
||||
|
||||
Scenario: A scheduled run with nothing to purge shows zeros, not "Never run"
|
||||
Given automatic purge on and due and no planned group
|
||||
When Tick runs and the client sends GET /v1/media/purge-settings
|
||||
Then "last_purge_at" is "2026-10-05T09:00:00Z", "last_purge_merged" is 0 and "last_purge_articles_updated" is 0
|
||||
|
||||
Scenario: A scheduled run answers no HTTP request and returns no message
|
||||
When Tick runs
|
||||
Then no HTTP handler is involved and the only observable results are the stored settings, the images, the article bodies and a log line
|
||||
|
||||
Rule: Layering and documentation
|
||||
|
||||
Scenario: The scheduler stays inside the domain boundary
|
||||
Then PurgeSchedulerUseCase imports only the stdlib, internal/domain/* and package-local use case types
|
||||
And internal/infrastructure/scheduler imports no persistence, mailer, models or gorm package
|
||||
And only api/dependecyInjection.go constructs it and only cmd/server/main.go starts it
|
||||
|
||||
Scenario: The docs describe the scheduler and state that there is no env var
|
||||
Then docs/architecture.md describes the scheduler, the hourly tick, the startup tick, the due rule, the overlap guard and the unbounded shutdown wait
|
||||
And docs/configuration.md states that automatic purge has no env var and that its schedule lives in the database
|
||||
And no new Go, TS, docs or config file contains a comment
|
||||
|
||||
@manual
|
||||
Rule: Wiring verified by hand (see the qa.md)
|
||||
|
||||
Scenario: The runner starts with the real server and stops on SIGINT or SIGTERM
|
||||
Given the server runs and automatic purge is on and due
|
||||
When one minute elapses after startup
|
||||
Then a purge ran and the Settings "Last purge" line shows the new date on the next visit
|
||||
When SIGTERM is sent during a run
|
||||
Then the process exits only after the run finished, and the SQLite file is not corrupted
|
||||
@@ -0,0 +1,56 @@
|
||||
# QA procedure: media_purge_scheduler
|
||||
|
||||
Package: `test` (black-box). New file `test/media_purge_scheduler_test.go`. Go only: no SPA. No env var. Fake type names must be unique in the package. Reuse part 1's `mockPurgeSettingsRepo`, `mockImageRepo`, `mockArticleRepo`, `seedGroup` and `articleUsing` from test/media_purge_test.go and test/media_purge_http_test.go. No real sleeping, no `time.Ticker`, no `time.Sleep`, no live server, no real SQLite file.
|
||||
|
||||
## Setup shared by every case
|
||||
1. `validator.InitValidator()` once (only needed by the HTTP cases).
|
||||
2. Fakes:
|
||||
- `fakeClock` (mutable, `Now()` and `Set(t)`), starting at 2026-10-05T09:00:00Z. Pass `clock.Now` as the `now func() time.Time` of RunPurge and UpdatePurgeSettings so last_purge_at and enabled_at come from it.
|
||||
- `mockPurgeSettingsRepo` with `Get`, `Save` and `SaveLastRun` (writes only the last_purge_* fields), failure switches for each, a write counter shared by Save and SaveLastRun, and a hook called before SaveLastRun writes.
|
||||
- A `fakePurgeRunPort` wrapping the real `RunPurgeUseCase`. It counts calls, records the fingerprint argument (must be nil), and can be armed to return an error. A second variant uses the real RunPurge directly for the integration cases.
|
||||
- Hand-fed `tickCh` and `startCh` channels passed to the runner in `internal/infrastructure/scheduler` in place of time.NewTicker and the startup delay. Use a `bytes.Buffer` logger.
|
||||
3. Wire the real `PurgeSchedulerUseCase` over the settings fake, the run port and the clock.
|
||||
|
||||
## Tick decisions (table-driven)
|
||||
4. Off never runs: off with ancient dates; the defaults; assert ran false, err nil, zero RunPurge calls, zero Save calls.
|
||||
5. Not due, due, boundary: enabled_at + interval one second after now gives no run; exactly now gives a run; assert the fingerprint argument is nil.
|
||||
6. Anchor table: the six rows of the outline. Cross-check each row against `Settings.NextPurgeAt()` to prove the scheduler reuses it.
|
||||
7. Enabled with unset enabled_at never runs and the scheduler never stamps it.
|
||||
8. Repeat the same Tick twice with the real RunPurge: one run (anchor moved), and `last_purge_at` equals the clock. Advance the clock past the interval: a third Tick runs.
|
||||
9. Empty library: due Tick records `last_purge_at` = clock, merged 0, articles_updated 0, and leaves auto_purge_enabled, interval and enabled_at untouched.
|
||||
10. Settings changes: shorten the interval (via `UpdatePurgeSettings` on the same fake) and a run becomes due; lengthen it and a due run is postponed; toggle off and no run; toggle off then on gives enabled_at = clock, no run now, a run once the clock passes +30 days. Assert `Get` is called once per Tick.
|
||||
|
||||
## Failures
|
||||
11. `failGet`: Tick returns the error, RunPurge count 0, the next Tick with the fake healed runs.
|
||||
12. RunPurge error (`ListDuplicates` failure): error returned, `last_purge_at` unchanged, one RunPurge call per Tick, recovery on the next Tick.
|
||||
13. `SaveLastRun` failure after merges: error returned, merged images still gone, next Tick converges.
|
||||
14. Partial failure (UpdateBody failing for group B): ran true, err nil, `last_purge_at` set, counters count group A only.
|
||||
15. Busy: block a manual `RunPurge.Run(nil)` inside the image fake (started channel). Call Tick with a due schedule and assert it returns promptly (use a timeout) with `errors.Is(err, purge.ErrPurgeBusy)`, with no `Save` from the scheduler. Release; the next Tick is not due because the manual run recorded `last_purge_at`. Run with `-race`.
|
||||
|
||||
## Runner
|
||||
16. Feed a startup signal: exactly one Tick, with the clock's time; no Tick before the signal.
|
||||
17. Feed three ticks: three Ticks. Feed none: no Tick. Assert no Tick between fed ticks.
|
||||
18. Error then success from Tick: the error text is in the logger buffer, the loop continues, the second Tick executes, and `Run` has not returned.
|
||||
19. Busy from Tick: no error line in the logger; the loop continues.
|
||||
19b. Completed run: Tick returns ran true with a known `purge.RunResult`; the logger buffer holds exactly one line with groups_purged, merged, articles_updated and failed_groups. An empty result logs zeros.
|
||||
19c. Tick result: a due Tick returns the run result; a Tick that is not due returns the empty result.
|
||||
19d. Lost update: block the settings fake just before `SaveLastRun` writes, PATCH auto_purge_enabled off and the interval to 5 through `UpdatePurgeSettings`, release; the stored auto_purge_* survive and last_purge_* are the run's. The persistence repository test checks that `SaveLastRun` leaves the other columns untouched and creates the default row when none exists.
|
||||
20. Cancel the context while idle: `Run` returns within a timeout; then feed a tick and assert no Tick happens. Cancel before the startup signal: no Tick at all.
|
||||
21. Shutdown waits without a bound: block RunPurge in the image fake, cancel the context, assert `Run` has not returned (select with a short timeout), release, assert `Run` returns and `last_purge_at` was recorded. `-race`.
|
||||
22. The runner and the use case spawn no goroutine that outlives `Run` (verify with a done channel closed on return).
|
||||
|
||||
## HTTP-observable check (existing settings endpoint)
|
||||
23. Mount the part 1 media router (same setup as media_purge.qa.md) over the same fakes. Seed on, interval 7, enabled_at 2026-09-20T09:00:00Z, one planned group. Tick, then GET `/v1/media/purge-settings` with the owner cookie. Assert `last_purge_at` 2026-10-05T09:00:00Z, counts equal the run, `next_purge_at` 2026-10-12T09:00:00Z, and the other fields unchanged. Repeat with an empty library: zeros and a non-null `last_purge_at`.
|
||||
|
||||
## Layering guards
|
||||
24. The scheduler use case imports no `net/http`, chi, gorm, `persistence`, `mailer` or `api/...`. Same grep-style test as media_purge.qa.md. `internal/infrastructure/scheduler` imports no `persistence`, `mailer`, `models` or `gorm`, and no `api/...`.
|
||||
25. Grep the new and edited files (Go, docs) for comments: none; only tool directives.
|
||||
|
||||
## Docs
|
||||
26. `docs/architecture.md` has a scheduler paragraph (hourly tick, startup tick, due rule from `NextPurgeAt`, overlap guard from RunPurge, unbounded shutdown wait, failures retried at the next tick). `docs/configuration.md` states that there is no env var and that the schedule lives in the database. Read both files and confirm.
|
||||
|
||||
## Manual (not automated)
|
||||
27. Real boot with `task dev:api` or the built binary: turn automatic purge on, set interval 1 and backdate `enabled_at` in the SQLite file (or wait); about a minute after start, a purge runs, `GET /v1/media/purge-settings` shows the new `last_purge_at`, and Settings "Last purge" is updated on the next visit.
|
||||
28. Send SIGINT or SIGTERM during a long run: the process exits only after the run ended, with no database error on close.
|
||||
29. A scheduled run and a manual "Purge now" at the same moment: one wins, the other answers 409 busy (HTTP) or is skipped (scheduler).
|
||||
30. Docker: the same behaviour in the container, with state on /data.
|
||||
@@ -46,3 +46,11 @@ func (s Settings) NextPurgeAt() *time.Time {
|
||||
next := base.AddDate(0, 0, s.AutoPurgeIntervalDays)
|
||||
return &next
|
||||
}
|
||||
|
||||
type RunResult struct {
|
||||
GroupsTotal int
|
||||
GroupsPurged int
|
||||
Merged int
|
||||
ArticlesUpdated int
|
||||
FailedGroups int
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package purge
|
||||
|
||||
import "time"
|
||||
|
||||
type Repository interface {
|
||||
Get() (Settings, error)
|
||||
Save(settings Settings) error
|
||||
SaveLastRun(at time.Time, merged int, articlesUpdated int) error
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -38,3 +39,19 @@ func (p *PurgeSettingsRepository) Save(settings domainPurge.Settings) error {
|
||||
model.ID = purgeSettingsRowID
|
||||
return p.db.Save(&model).Error
|
||||
}
|
||||
|
||||
func (p *PurgeSettingsRepository) SaveLastRun(at time.Time, merged int, articlesUpdated int) error {
|
||||
result := p.db.Model(&entity.PurgeSettings{}).Where("id = ?", purgeSettingsRowID).Updates(map[string]any{
|
||||
"last_purge_at": at,
|
||||
"last_purge_merged": merged,
|
||||
"last_purge_articles_updated": articlesUpdated,
|
||||
})
|
||||
if result.Error != nil || result.RowsAffected > 0 {
|
||||
return result.Error
|
||||
}
|
||||
settings := domainPurge.DefaultSettings()
|
||||
settings.LastPurgeAt = &at
|
||||
settings.LastPurgeMerged = merged
|
||||
settings.LastPurgeArticlesUpdated = articlesUpdated
|
||||
return p.Save(settings)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
TickInterval = time.Hour
|
||||
StartupDelay = time.Minute
|
||||
)
|
||||
|
||||
type Job interface {
|
||||
Tick(now time.Time) (purge.RunResult, bool, error)
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
job Job
|
||||
now func() time.Time
|
||||
ticks <-chan time.Time
|
||||
startup <-chan time.Time
|
||||
logger *log.Logger
|
||||
release func()
|
||||
}
|
||||
|
||||
func NewRunner(job Job, now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time, logger *log.Logger) *Runner {
|
||||
return &Runner{job: job, now: now, ticks: ticks, startup: startup, logger: logger, release: func() {}}
|
||||
}
|
||||
|
||||
func NewSystemRunner(job Job, logger *log.Logger) *Runner {
|
||||
ticker := time.NewTicker(TickInterval)
|
||||
startup := time.NewTimer(StartupDelay)
|
||||
runner := NewRunner(job, time.Now, ticker.C, startup.C, logger)
|
||||
runner.release = func() {
|
||||
ticker.Stop()
|
||||
startup.Stop()
|
||||
}
|
||||
return runner
|
||||
}
|
||||
|
||||
func (r *Runner) Run(ctx context.Context) {
|
||||
defer r.release()
|
||||
startup := r.startup
|
||||
for ctx.Err() == nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-startup:
|
||||
startup = nil
|
||||
r.tick(ctx)
|
||||
case <-r.ticks:
|
||||
r.tick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) tick(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
result, ran, err := r.job.Tick(r.now())
|
||||
if errors.Is(err, purge.ErrPurgeBusy) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
r.logger.Printf("scheduler: automatic purge failed: %v", err)
|
||||
return
|
||||
}
|
||||
if ran {
|
||||
r.logger.Printf("scheduler: automatic purge completed: groups_purged=%d merged=%d articles_updated=%d failed_groups=%d", result.GroupsPurged, result.Merged, result.ArticlesUpdated, result.FailedGroups)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PurgeRunner interface {
|
||||
Run(fingerprint *string) (purge.RunResult, error)
|
||||
}
|
||||
|
||||
type PurgeSchedulerUseCase struct {
|
||||
repository purge.Repository
|
||||
runner PurgeRunner
|
||||
}
|
||||
|
||||
func NewPurgeSchedulerUseCase(repository purge.Repository, runner PurgeRunner) *PurgeSchedulerUseCase {
|
||||
return &PurgeSchedulerUseCase{repository, runner}
|
||||
}
|
||||
|
||||
func (u *PurgeSchedulerUseCase) Tick(now time.Time) (purge.RunResult, bool, error) {
|
||||
settings, err := u.repository.Get()
|
||||
if err != nil {
|
||||
return purge.RunResult{}, false, err
|
||||
}
|
||||
next := settings.NextPurgeAt()
|
||||
if next == nil || now.Before(*next) {
|
||||
return purge.RunResult{}, false, nil
|
||||
}
|
||||
result, err := u.runner.Run(nil)
|
||||
if err != nil {
|
||||
return purge.RunResult{}, false, err
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
@@ -10,14 +10,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type RunPurgeResult struct {
|
||||
GroupsTotal int
|
||||
GroupsPurged int
|
||||
Merged int
|
||||
ArticlesUpdated int
|
||||
FailedGroups int
|
||||
}
|
||||
|
||||
type RunPurgeUseCase struct {
|
||||
planner *purgePlanner
|
||||
merge *MergeMediaUseCase
|
||||
@@ -37,30 +29,30 @@ func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository arti
|
||||
}
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) Run(fingerprint *string) (RunPurgeResult, error) {
|
||||
func (u *RunPurgeUseCase) Run(fingerprint *string) (purge.RunResult, error) {
|
||||
if !u.guard.TryLock() {
|
||||
return RunPurgeResult{}, purge.ErrPurgeBusy
|
||||
return purge.RunResult{}, purge.ErrPurgeBusy
|
||||
}
|
||||
defer u.guard.Unlock()
|
||||
|
||||
plan, err := u.planner.plan()
|
||||
if err != nil {
|
||||
return RunPurgeResult{}, err
|
||||
return purge.RunResult{}, err
|
||||
}
|
||||
if fingerprint != nil && *fingerprint != plan.fingerprint {
|
||||
return RunPurgeResult{}, purge.ErrPurgeConflict
|
||||
return purge.RunResult{}, purge.ErrPurgeConflict
|
||||
}
|
||||
|
||||
result := u.mergeGroups(plan)
|
||||
if err := u.recordRun(result); err != nil {
|
||||
u.logger.Printf("purge: could not record the run result: %v", err)
|
||||
return RunPurgeResult{}, err
|
||||
return purge.RunResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) mergeGroups(plan purgePlan) RunPurgeResult {
|
||||
result := RunPurgeResult{GroupsTotal: len(plan.groups)}
|
||||
func (u *RunPurgeUseCase) mergeGroups(plan purgePlan) purge.RunResult {
|
||||
result := purge.RunResult{GroupsTotal: len(plan.groups)}
|
||||
updated := map[uint32]bool{}
|
||||
for _, group := range plan.groups {
|
||||
merged, err := u.merge.Merge(group.kept.ID, slices.Clone(group.mergedIds))
|
||||
@@ -78,14 +70,6 @@ func (u *RunPurgeUseCase) mergeGroups(plan purgePlan) RunPurgeResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) recordRun(result RunPurgeResult) error {
|
||||
settings, err := u.repository.Get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
at := u.now().UTC()
|
||||
settings.LastPurgeAt = &at
|
||||
settings.LastPurgeMerged = result.Merged
|
||||
settings.LastPurgeArticlesUpdated = result.ArticlesUpdated
|
||||
return u.repository.Save(settings)
|
||||
func (u *RunPurgeUseCase) recordRun(result purge.RunResult) error {
|
||||
return u.repository.SaveLastRun(u.now().UTC(), result.Merged, result.ArticlesUpdated)
|
||||
}
|
||||
@@ -24,17 +24,27 @@ const (
|
||||
)
|
||||
|
||||
type mockPurgeSettingsRepo struct {
|
||||
settings purge.Settings
|
||||
fail failSwitch
|
||||
getCalls int
|
||||
saveCalls int
|
||||
mu sync.Mutex
|
||||
settings purge.Settings
|
||||
fail failSwitch
|
||||
getCalls int
|
||||
saveCalls int
|
||||
beforeRunWrite func()
|
||||
}
|
||||
|
||||
func newMockPurgeSettingsRepo() *mockPurgeSettingsRepo {
|
||||
return &mockPurgeSettingsRepo{settings: purge.DefaultSettings()}
|
||||
}
|
||||
|
||||
func (m *mockPurgeSettingsRepo) snapshot() purge.Settings {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.settings
|
||||
}
|
||||
|
||||
func (m *mockPurgeSettingsRepo) Get() (purge.Settings, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.getCalls++
|
||||
if err := m.fail.trigger("Get"); err != nil {
|
||||
return purge.Settings{}, err
|
||||
@@ -43,6 +53,8 @@ func (m *mockPurgeSettingsRepo) Get() (purge.Settings, error) {
|
||||
}
|
||||
|
||||
func (m *mockPurgeSettingsRepo) Save(settings purge.Settings) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.saveCalls++
|
||||
if err := m.fail.trigger("Save"); err != nil {
|
||||
return err
|
||||
@@ -51,6 +63,25 @@ func (m *mockPurgeSettingsRepo) Save(settings purge.Settings) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPurgeSettingsRepo) SaveLastRun(at time.Time, merged int, articlesUpdated int) error {
|
||||
m.mu.Lock()
|
||||
m.saveCalls++
|
||||
hook := m.beforeRunWrite
|
||||
m.mu.Unlock()
|
||||
if hook != nil {
|
||||
hook()
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err := m.fail.trigger("SaveLastRun"); err != nil {
|
||||
return err
|
||||
}
|
||||
m.settings.LastPurgeAt = &at
|
||||
m.settings.LastPurgeMerged = merged
|
||||
m.settings.LastPurgeArticlesUpdated = articlesUpdated
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ purge.Repository = &mockPurgeSettingsRepo{}
|
||||
|
||||
func purgeTime(value string) *time.Time {
|
||||
@@ -796,7 +827,7 @@ func TestPurgeRun(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.useImages(10, 1)
|
||||
env.purgeSettings.fail.failNext("Save")
|
||||
env.purgeSettings.fail.failNext("SaveLastRun")
|
||||
|
||||
expectRaw(t, env.run(t, `{}`), 500, internalError)
|
||||
expectIds(t, env.imageIds(), []uint32{1})
|
||||
|
||||
@@ -64,6 +64,21 @@ func TestPurgeLayering(t *testing.T) {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the scheduler imports only the stdlib and domain packages", func(t *testing.T) {
|
||||
imports := list(t, `{{join .Imports "\n"}}`, "RenewCMS/internal/infrastructure/scheduler")
|
||||
if len(imports) == 0 {
|
||||
t.Fatal("expected imports")
|
||||
}
|
||||
for _, imported := range imports {
|
||||
if strings.Contains(imported, ".") && !strings.HasPrefix(imported, "RenewCMS/internal/domain/") {
|
||||
t.Errorf("unexpected import %s", imported)
|
||||
}
|
||||
if strings.HasPrefix(imported, "RenewCMS/") && !strings.HasPrefix(imported, "RenewCMS/internal/domain/") {
|
||||
t.Errorf("scheduler must not import %s", imported)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurgeSettingsRepositoryRoundTrip(t *testing.T) {
|
||||
@@ -107,6 +122,39 @@ func TestPurgeSettingsRepositoryRoundTrip(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SaveLastRun writes only the last_purge_* columns", func(t *testing.T) {
|
||||
repo := newRepo(t)
|
||||
enabled := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
if err := repo.Save(domainPurge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 9, EnabledAt: &enabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := repo.SaveLastRun(at, 4, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := repo.Get()
|
||||
if err != nil || !got.AutoPurgeEnabled || got.AutoPurgeIntervalDays != 9 || got.EnabledAt == nil || !got.EnabledAt.Equal(enabled) || got.LastPurgeAt == nil || !got.LastPurgeAt.Equal(at) || got.LastPurgeMerged != 4 || got.LastPurgeArticlesUpdated != 2 {
|
||||
t.Errorf("unexpected settings %+v, %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SaveLastRun with no saved row keeps the defaults", func(t *testing.T) {
|
||||
repo := newRepo(t)
|
||||
at := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := repo.SaveLastRun(at, 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := repo.Get()
|
||||
defaults := domainPurge.DefaultSettings()
|
||||
if err != nil || got.AutoPurgeEnabled != defaults.AutoPurgeEnabled || got.AutoPurgeIntervalDays != defaults.AutoPurgeIntervalDays || got.LastPurgeAt == nil || !got.LastPurgeAt.Equal(at) {
|
||||
t.Errorf("unexpected settings %+v, %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Repeated saves keep a single row", func(t *testing.T) {
|
||||
db := openInMemoryDb(t)
|
||||
if err := db.AutoMigrate(&models.PurgeSettings{}); err != nil {
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/infrastructure/scheduler"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
schedulerClock = "2026-10-05T09:00:00Z"
|
||||
schedulerWaitTimeout = 2 * time.Second
|
||||
schedulerQuietPeriod = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
type schedulerEnv struct {
|
||||
*mediaEnv
|
||||
runPurge *useCases.RunPurgeUseCase
|
||||
scheduler *useCases.PurgeSchedulerUseCase
|
||||
update *useCases.UpdatePurgeSettingsUseCase
|
||||
}
|
||||
|
||||
func newSchedulerEnv(t *testing.T) *schedulerEnv {
|
||||
t.Helper()
|
||||
env := newPurgeEnv(t)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
|
||||
now := func() time.Time { return env.clock }
|
||||
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0))
|
||||
return &schedulerEnv{
|
||||
mediaEnv: env,
|
||||
runPurge: runPurge,
|
||||
scheduler: useCases.NewPurgeSchedulerUseCase(env.purgeSettings, runPurge),
|
||||
update: useCases.NewUpdatePurgeSettingsUseCase(env.purgeSettings, now),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *schedulerEnv) configure(enabled bool, interval int, enabledAt string, lastPurgeAt string) {
|
||||
settings := purge.Settings{AutoPurgeEnabled: enabled, AutoPurgeIntervalDays: interval}
|
||||
if enabledAt != "" {
|
||||
settings.EnabledAt = purgeTime(enabledAt)
|
||||
}
|
||||
if lastPurgeAt != "" {
|
||||
settings.LastPurgeAt = purgeTime(lastPurgeAt)
|
||||
}
|
||||
e.purgeSettings.settings = settings
|
||||
}
|
||||
|
||||
func (e *schedulerEnv) configureDue() {
|
||||
e.configure(true, 10, "2026-09-20T09:00:00Z", "")
|
||||
}
|
||||
|
||||
func (e *schedulerEnv) seedPlannedGroup() {
|
||||
e.seedGroup(hashA, 1, 2)
|
||||
e.useImages(1, 1, 2)
|
||||
}
|
||||
|
||||
func (e *schedulerEnv) tick(t *testing.T, at string) (bool, error) {
|
||||
t.Helper()
|
||||
_, ran, err := e.scheduler.Tick(*purgeTime(at))
|
||||
return ran, err
|
||||
}
|
||||
|
||||
func (e *schedulerEnv) expectTick(t *testing.T, at string, wantRan bool) {
|
||||
t.Helper()
|
||||
ran, err := e.tick(t, at)
|
||||
if err != nil || ran != wantRan {
|
||||
t.Fatalf("expected ran %v and no error at %s, got %v and %v", wantRan, at, ran, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *schedulerEnv) expectLastPurge(t *testing.T, at string, merged int, updated int) {
|
||||
t.Helper()
|
||||
settings := e.purgeSettings.settings
|
||||
if settings.LastPurgeAt == nil || !settings.LastPurgeAt.Equal(*purgeTime(at)) || settings.LastPurgeMerged != merged || settings.LastPurgeArticlesUpdated != updated {
|
||||
t.Fatalf("unexpected last purge %+v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeSchedulerDecidesWhenARunIsDue(t *testing.T) {
|
||||
t.Run("Automatic purge off never runs, whatever the dates are", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(false, 1, "2020-01-01T00:00:00Z", "2020-01-02T00:00:00Z")
|
||||
env.seedPlannedGroup()
|
||||
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
|
||||
if env.purgeSettings.saveCalls != 0 || len(env.images.deleteCalls) != 0 {
|
||||
t.Errorf("expected nothing to run or be saved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The defaults never run", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.seedPlannedGroup()
|
||||
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
|
||||
if len(env.images.deleteCalls) != 0 {
|
||||
t.Errorf("expected no run")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("On, never ran, not due yet", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 30, "2026-09-20T09:00:00Z", "")
|
||||
env.seedPlannedGroup()
|
||||
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
|
||||
if env.purgeSettings.saveCalls != 0 {
|
||||
t.Errorf("expected no run")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("On, never ran, due since enabled_at plus the interval", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 10, "2026-09-25T09:00:00Z", "")
|
||||
env.seedPlannedGroup()
|
||||
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
|
||||
if env.purgeSettings.saveCalls != 1 || len(env.images.deleteCalls) != 1 {
|
||||
t.Errorf("expected exactly one run")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The anchor is the later of last_purge_at and enabled_at", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
enabledAt string
|
||||
lastPurgeAt string
|
||||
ran bool
|
||||
}{
|
||||
{"last purge plus interval is past", "2026-09-01T00:00:00Z", "2026-09-20T00:00:00Z", true},
|
||||
{"last purge plus interval is ahead", "2026-09-01T00:00:00Z", "2026-09-28T00:00:00Z", false},
|
||||
{"enabled_at is later and ahead", "2026-09-28T00:00:00Z", "2026-09-01T00:00:00Z", false},
|
||||
{"enabled_at is later and past", "2026-09-20T00:00:00Z", "2026-09-01T00:00:00Z", true},
|
||||
{"exactly now is due", "2026-09-25T09:00:00Z", "", true},
|
||||
{"one second before due", "2026-09-25T09:00:01Z", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 10, c.enabledAt, c.lastPurgeAt)
|
||||
|
||||
env.expectTick(t, schedulerClock, c.ran)
|
||||
|
||||
wantSaves := 0
|
||||
if c.ran {
|
||||
wantSaves = 1
|
||||
}
|
||||
if env.purgeSettings.saveCalls != wantSaves {
|
||||
t.Errorf("expected %d runs, got %d", wantSaves, env.purgeSettings.saveCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Due exactly at the boundary, not before", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 10, "2026-09-25T09:00:00Z", "")
|
||||
|
||||
env.expectTick(t, "2026-10-05T08:59:59Z", false)
|
||||
env.expectTick(t, "2026-10-05T09:00:00Z", true)
|
||||
})
|
||||
|
||||
t.Run("A run advances the anchor, so the next tick is not due", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.seedPlannedGroup()
|
||||
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
|
||||
if env.purgeSettings.saveCalls != 1 {
|
||||
t.Errorf("expected one run")
|
||||
}
|
||||
env.expectLastPurge(t, schedulerClock, 1, 1)
|
||||
})
|
||||
|
||||
t.Run("A due run that finds nothing to purge still moves the clock forward", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
|
||||
env.expectLastPurge(t, schedulerClock, 0, 0)
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
env.expectTick(t, "2026-10-15T09:00:00Z", true)
|
||||
})
|
||||
|
||||
t.Run("A run keeps auto_purge_enabled, the interval and enabled_at", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
before := env.purgeSettings.settings
|
||||
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
|
||||
after := env.purgeSettings.settings
|
||||
if !after.AutoPurgeEnabled || after.AutoPurgeIntervalDays != before.AutoPurgeIntervalDays || !after.EnabledAt.Equal(*before.EnabledAt) {
|
||||
t.Errorf("unexpected settings %+v", after)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Enabled with no enabled_at is not due", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 1, "", "")
|
||||
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
|
||||
if env.purgeSettings.saveCalls != 0 || env.purgeSettings.settings.EnabledAt != nil {
|
||||
t.Errorf("expected nothing stamped or saved")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurgeSchedulerFollowsSettingsChanges(t *testing.T) {
|
||||
updateInterval := func(env *schedulerEnv, days int) {
|
||||
if _, err := env.update.Update(useCases.UpdatePurgeSettingsCommand{AutoPurgeIntervalDays: &days}); err != nil {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("Shortening the interval makes a run due on the next tick", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 30, "2026-09-25T09:00:00Z", "")
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
|
||||
updateInterval(env, 7)
|
||||
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
})
|
||||
|
||||
t.Run("Lengthening the interval postpones the run", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 10, "2026-09-20T09:00:00Z", "")
|
||||
|
||||
updateInterval(env, 30)
|
||||
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
})
|
||||
|
||||
t.Run("Turning it off stops runs", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
disabled := false
|
||||
|
||||
if _, err := env.update.Update(useCases.UpdatePurgeSettingsCommand{AutoPurgeEnabled: &disabled}); err != nil {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
})
|
||||
|
||||
t.Run("Turning it on resets the anchor to the moment of the switch", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(false, 30, "2026-08-01T00:00:00Z", "2026-09-01T00:00:00Z")
|
||||
enabled := true
|
||||
|
||||
if _, err := env.update.Update(useCases.UpdatePurgeSettingsCommand{AutoPurgeEnabled: &enabled}); err != nil {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
env.expectTick(t, "2026-11-04T09:00:00Z", true)
|
||||
})
|
||||
|
||||
t.Run("The scheduler reads the settings at every tick", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
env.configureDue()
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
env.configure(false, 10, "2026-09-20T09:00:00Z", "")
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
|
||||
if env.purgeSettings.getCalls < 3 {
|
||||
t.Errorf("expected at least three reads, got %d", env.purgeSettings.getCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurgeSchedulerReportsTheRunResult(t *testing.T) {
|
||||
t.Run("A due Tick returns what the run did", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.seedPlannedGroup()
|
||||
|
||||
result, ran, err := env.scheduler.Tick(*purgeTime(schedulerClock))
|
||||
|
||||
want := purge.RunResult{GroupsTotal: 1, GroupsPurged: 1, Merged: 1, ArticlesUpdated: 1}
|
||||
if err != nil || !ran || result != want {
|
||||
t.Errorf("expected %+v, got %+v, %v, %v", want, result, ran, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A Tick that is not due returns an empty result", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
|
||||
result, ran, err := env.scheduler.Tick(*purgeTime(schedulerClock))
|
||||
|
||||
if err != nil || ran || result != (purge.RunResult{}) {
|
||||
t.Errorf("unexpected %+v, %v, %v", result, ran, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurgeRunKeepsSettingsChangedWhileItRuns(t *testing.T) {
|
||||
t.Run("A settings change made during a run survives, only last_purge_* are written by the run", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.seedPlannedGroup()
|
||||
writing := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
env.purgeSettings.beforeRunWrite = func() {
|
||||
close(writing)
|
||||
<-release
|
||||
}
|
||||
runDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := env.runPurge.Run(nil)
|
||||
runDone <- err
|
||||
}()
|
||||
waitFor(t, writing)
|
||||
disabled := false
|
||||
days := 5
|
||||
if _, err := env.update.Update(useCases.UpdatePurgeSettingsCommand{AutoPurgeEnabled: &disabled, AutoPurgeIntervalDays: &days}); err != nil {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
|
||||
close(release)
|
||||
if err := waitFor(t, runDone); err != nil {
|
||||
t.Fatalf("unexpected run error %v", err)
|
||||
}
|
||||
|
||||
settings := env.purgeSettings.settings
|
||||
if settings.AutoPurgeEnabled || settings.AutoPurgeIntervalDays != 5 {
|
||||
t.Errorf("expected the change to survive, got %+v", settings)
|
||||
}
|
||||
env.expectLastPurge(t, schedulerClock, 1, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurgeSchedulerFailures(t *testing.T) {
|
||||
t.Run("Reading the settings fails", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.purgeSettings.fail.failNext("Get")
|
||||
|
||||
ran, err := env.tick(t, schedulerClock)
|
||||
|
||||
if ran || !errors.Is(err, errRepositoryFailure) {
|
||||
t.Fatalf("expected the repository error, got %v and %v", ran, err)
|
||||
}
|
||||
if len(env.images.deleteCalls) != 0 {
|
||||
t.Errorf("expected no run")
|
||||
}
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
})
|
||||
|
||||
t.Run("RunPurge returns an error", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.images.fail.failNext("ListDuplicates")
|
||||
|
||||
ran, err := env.tick(t, schedulerClock)
|
||||
|
||||
if ran || !errors.Is(err, errRepositoryFailure) {
|
||||
t.Fatalf("expected the repository error, got %v and %v", ran, err)
|
||||
}
|
||||
if env.purgeSettings.settings.LastPurgeAt != nil || env.purgeSettings.saveCalls != 0 {
|
||||
t.Errorf("expected last_purge_at unchanged")
|
||||
}
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
})
|
||||
|
||||
t.Run("Saving the run result fails after the merges succeeded", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.seedPlannedGroup()
|
||||
env.purgeSettings.fail.failNext("SaveLastRun")
|
||||
|
||||
ran, err := env.tick(t, schedulerClock)
|
||||
|
||||
if ran || !errors.Is(err, errRepositoryFailure) {
|
||||
t.Fatalf("expected the repository error, got %v and %v", ran, err)
|
||||
}
|
||||
if len(env.images.deleteCalls) != 1 {
|
||||
t.Errorf("expected the merge to stay done")
|
||||
}
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
env.expectLastPurge(t, schedulerClock, 0, 0)
|
||||
})
|
||||
|
||||
t.Run("A run that partially fails still records what it merged", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.seedGroup(hashB, 3, 4)
|
||||
env.useImages(20, 1, 2)
|
||||
env.useImages(21, 3, 4)
|
||||
env.articles.failUpdateBodyFor = map[uint32]error{21: errRepositoryFailure}
|
||||
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
|
||||
env.expectLastPurge(t, schedulerClock, 1, 1)
|
||||
env.articles.failUpdateBodyFor = nil
|
||||
if plan := env.preview(t); numberOf(plan["groups"]) != 1 {
|
||||
t.Errorf("expected group B to be planned again, got %v", plan)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A manual run is in progress", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.seedPlannedGroup()
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
env.images.onDelete = func(uint32) {
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
manualDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := env.runPurge.Run(nil)
|
||||
manualDone <- err
|
||||
}()
|
||||
waitFor(t, entered)
|
||||
|
||||
ran, err := env.tick(t, schedulerClock)
|
||||
|
||||
if ran || !errors.Is(err, purge.ErrPurgeBusy) {
|
||||
t.Fatalf("expected ErrPurgeBusy, got %v and %v", ran, err)
|
||||
}
|
||||
close(release)
|
||||
if err := <-manualDone; err != nil {
|
||||
t.Fatalf("unexpected manual error %v", err)
|
||||
}
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
})
|
||||
}
|
||||
|
||||
type schedulerJobFake struct {
|
||||
mu sync.Mutex
|
||||
calls []time.Time
|
||||
results []schedulerJobResult
|
||||
entered chan time.Time
|
||||
gate chan struct{}
|
||||
}
|
||||
|
||||
type schedulerJobResult struct {
|
||||
result purge.RunResult
|
||||
ran bool
|
||||
err error
|
||||
}
|
||||
|
||||
func newSchedulerJobFake(results ...schedulerJobResult) *schedulerJobFake {
|
||||
return &schedulerJobFake{results: results, entered: make(chan time.Time, 16)}
|
||||
}
|
||||
|
||||
func (f *schedulerJobFake) Tick(now time.Time) (purge.RunResult, bool, error) {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, now)
|
||||
var result schedulerJobResult
|
||||
if len(f.results) > 0 {
|
||||
result = f.results[0]
|
||||
f.results = f.results[1:]
|
||||
}
|
||||
gate := f.gate
|
||||
f.mu.Unlock()
|
||||
f.entered <- now
|
||||
if gate != nil {
|
||||
<-gate
|
||||
}
|
||||
return result.result, result.ran, result.err
|
||||
}
|
||||
|
||||
func (f *schedulerJobFake) callCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.calls)
|
||||
}
|
||||
|
||||
type schedulerClockFake struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *schedulerClockFake) set(value string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.now = *purgeTime(value)
|
||||
}
|
||||
|
||||
func (c *schedulerClockFake) read() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
type runnerHarness struct {
|
||||
job *schedulerJobFake
|
||||
clock *schedulerClockFake
|
||||
ticks chan time.Time
|
||||
startup chan time.Time
|
||||
logs *bytes.Buffer
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func startRunner(t *testing.T, job *schedulerJobFake) *runnerHarness {
|
||||
t.Helper()
|
||||
h := &runnerHarness{
|
||||
job: job,
|
||||
clock: &schedulerClockFake{},
|
||||
ticks: make(chan time.Time, 8),
|
||||
startup: make(chan time.Time, 1),
|
||||
logs: &bytes.Buffer{},
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
h.clock.set(schedulerClock)
|
||||
runner := scheduler.NewRunner(job, h.clock.read, h.ticks, h.startup, log.New(h.logs, "", 0))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h.cancel = cancel
|
||||
go func() {
|
||||
defer close(h.done)
|
||||
runner.Run(ctx)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
select {
|
||||
case <-h.done:
|
||||
case <-time.After(schedulerWaitTimeout):
|
||||
}
|
||||
})
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *runnerHarness) expectStopped(t *testing.T) {
|
||||
t.Helper()
|
||||
waitFor(t, h.done)
|
||||
}
|
||||
|
||||
func waitFor[T any](t *testing.T, ch <-chan T) T {
|
||||
t.Helper()
|
||||
select {
|
||||
case value := <-ch:
|
||||
return value
|
||||
case <-time.After(schedulerWaitTimeout):
|
||||
t.Fatalf("timed out waiting")
|
||||
panic("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeSchedulerRunner(t *testing.T) {
|
||||
t.Run("A failing tick does not stop the runner", func(t *testing.T) {
|
||||
job := newSchedulerJobFake(schedulerJobResult{err: errRepositoryFailure}, schedulerJobResult{ran: true})
|
||||
h := startRunner(t, job)
|
||||
|
||||
h.ticks <- time.Time{}
|
||||
waitFor(t, job.entered)
|
||||
time.Sleep(schedulerQuietPeriod)
|
||||
if job.callCount() != 1 {
|
||||
t.Fatalf("expected no retry between ticks, got %d calls", job.callCount())
|
||||
}
|
||||
h.ticks <- time.Time{}
|
||||
waitFor(t, job.entered)
|
||||
|
||||
h.cancel()
|
||||
h.expectStopped(t)
|
||||
if !strings.Contains(h.logs.String(), errRepositoryFailure.Error()) {
|
||||
t.Errorf("expected the error to be logged, got %q", h.logs.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A busy skip is not logged as an error", func(t *testing.T) {
|
||||
job := newSchedulerJobFake(schedulerJobResult{err: purge.ErrPurgeBusy})
|
||||
h := startRunner(t, job)
|
||||
|
||||
h.ticks <- time.Time{}
|
||||
waitFor(t, job.entered)
|
||||
h.cancel()
|
||||
h.expectStopped(t)
|
||||
|
||||
if h.logs.Len() != 0 {
|
||||
t.Errorf("expected no log line, got %q", h.logs.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A completed run logs one line with the counts", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result purge.RunResult
|
||||
want string
|
||||
}{
|
||||
{"a run that purged", purge.RunResult{GroupsTotal: 4, GroupsPurged: 3, Merged: 5, ArticlesUpdated: 2, FailedGroups: 1}, "scheduler: automatic purge completed: groups_purged=3 merged=5 articles_updated=2 failed_groups=1\n"},
|
||||
{"an empty run", purge.RunResult{}, "scheduler: automatic purge completed: groups_purged=0 merged=0 articles_updated=0 failed_groups=0\n"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
job := newSchedulerJobFake(schedulerJobResult{result: c.result, ran: true})
|
||||
h := startRunner(t, job)
|
||||
|
||||
h.ticks <- time.Time{}
|
||||
waitFor(t, job.entered)
|
||||
h.cancel()
|
||||
h.expectStopped(t)
|
||||
|
||||
if h.logs.String() != c.want {
|
||||
t.Errorf("expected %q, got %q", c.want, h.logs.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("One Tick shortly after startup", func(t *testing.T) {
|
||||
job := newSchedulerJobFake()
|
||||
h := startRunner(t, job)
|
||||
time.Sleep(schedulerQuietPeriod)
|
||||
if job.callCount() != 0 {
|
||||
t.Fatalf("expected no Tick before the signal")
|
||||
}
|
||||
|
||||
h.startup <- time.Time{}
|
||||
|
||||
if got := waitFor(t, job.entered); !got.Equal(*purgeTime(schedulerClock)) {
|
||||
t.Errorf("expected the clock's time, got %s", got)
|
||||
}
|
||||
time.Sleep(schedulerQuietPeriod)
|
||||
if job.callCount() != 1 {
|
||||
t.Errorf("expected one Tick, got %d", job.callCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A due run is caught up at startup after downtime", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 30, "2026-08-01T00:00:00Z", "2026-08-26T09:00:00Z")
|
||||
env.seedPlannedGroup()
|
||||
clock := &schedulerClockFake{}
|
||||
clock.set(schedulerClock)
|
||||
ticks := make(chan time.Time, 8)
|
||||
startup := make(chan time.Time, 1)
|
||||
runner := scheduler.NewRunner(env.scheduler, clock.read, ticks, startup, log.New(env.logs, "", 0))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
runner.Run(ctx)
|
||||
}()
|
||||
|
||||
startup <- time.Time{}
|
||||
deadline := time.Now().Add(schedulerWaitTimeout)
|
||||
for lastPurgeAt := env.purgeSettings.snapshot().LastPurgeAt; lastPurgeAt == nil || lastPurgeAt.Before(*purgeTime(schedulerClock)); lastPurgeAt = env.purgeSettings.snapshot().LastPurgeAt {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("the startup run did not happen")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
waitFor(t, done)
|
||||
|
||||
if env.purgeSettings.saveCalls != 1 {
|
||||
t.Errorf("expected exactly one run, got %d", env.purgeSettings.saveCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A Tick on each hourly tick", func(t *testing.T) {
|
||||
job := newSchedulerJobFake()
|
||||
h := startRunner(t, job)
|
||||
|
||||
for _, at := range []string{"2026-10-05T10:00:00Z", "2026-10-05T11:00:00Z", "2026-10-05T12:00:00Z"} {
|
||||
h.clock.set(at)
|
||||
h.ticks <- time.Time{}
|
||||
if got := waitFor(t, job.entered); !got.Equal(*purgeTime(at)) {
|
||||
t.Errorf("expected %s, got %s", at, got)
|
||||
}
|
||||
}
|
||||
|
||||
if job.callCount() != 3 {
|
||||
t.Errorf("expected three Ticks, got %d", job.callCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Cancelling the context stops the runner", func(t *testing.T) {
|
||||
job := newSchedulerJobFake()
|
||||
h := startRunner(t, job)
|
||||
|
||||
h.cancel()
|
||||
h.expectStopped(t)
|
||||
h.ticks <- time.Time{}
|
||||
time.Sleep(schedulerQuietPeriod)
|
||||
|
||||
if job.callCount() != 0 {
|
||||
t.Errorf("expected no Tick after cancel")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Cancelled before the startup signal", func(t *testing.T) {
|
||||
job := newSchedulerJobFake()
|
||||
h := startRunner(t, job)
|
||||
|
||||
h.cancel()
|
||||
h.startup <- time.Time{}
|
||||
h.expectStopped(t)
|
||||
|
||||
if job.callCount() != 0 {
|
||||
t.Errorf("expected no Tick")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Shutdown waits for a run in progress without a time limit", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.seedPlannedGroup()
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
env.images.onDelete = func(uint32) {
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
clock := &schedulerClockFake{}
|
||||
clock.set(schedulerClock)
|
||||
ticks := make(chan time.Time, 1)
|
||||
runner := scheduler.NewRunner(env.scheduler, clock.read, ticks, make(chan time.Time), log.New(env.logs, "", 0))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
runner.Run(ctx)
|
||||
}()
|
||||
ticks <- time.Time{}
|
||||
waitFor(t, entered)
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatalf("Run returned while a purge was in progress")
|
||||
case <-time.After(schedulerQuietPeriod):
|
||||
}
|
||||
close(release)
|
||||
waitFor(t, done)
|
||||
|
||||
env.expectLastPurge(t, schedulerClock, 1, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurgeSchedulerResultIsServedBySettings(t *testing.T) {
|
||||
t.Run("A scheduled run updates the Last purge values served by the settings endpoint", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 7, "2026-09-20T09:00:00Z", "")
|
||||
env.seedPlannedGroup()
|
||||
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
rec := env.purgeSend(t, http.MethodGet, "/v1/media/purge-settings", "")
|
||||
|
||||
body := decodeObject(t, rec.Body.String())
|
||||
if body["last_purge_at"] != schedulerClock || numberOf(body["last_purge_merged"]) != 1 || numberOf(body["last_purge_articles_updated"]) != 1 || body["next_purge_at"] != "2026-10-12T09:00:00Z" || body["auto_purge_enabled"] != true || numberOf(body["auto_purge_interval_days"]) != 7 || body["enabled_at"] != "2026-09-20T09:00:00Z" {
|
||||
t.Errorf("unexpected body %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A scheduled run with nothing to purge shows zeros, not Never run", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
rec := env.purgeSend(t, http.MethodGet, "/v1/media/purge-settings", "")
|
||||
|
||||
body := decodeObject(t, rec.Body.String())
|
||||
if body["last_purge_at"] != schedulerClock || numberOf(body["last_purge_merged"]) != 0 || numberOf(body["last_purge_articles_updated"]) != 0 {
|
||||
t.Errorf("unexpected body %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
|
||||
|
||||
result, err := newRun(env).Run(nil)
|
||||
|
||||
if err != nil || result != (useCases.RunPurgeResult{GroupsTotal: 1, GroupsPurged: 1, Merged: 1}) {
|
||||
if err != nil || result != (purge.RunResult{GroupsTotal: 1, GroupsPurged: 1, Merged: 1}) {
|
||||
t.Errorf("unexpected result %+v, %v", result, err)
|
||||
}
|
||||
if env.purgeSettings.settings.LastPurgeAt == nil || env.purgeSettings.settings.LastPurgeMerged != 1 {
|
||||
|
||||
Reference in new issue
Block a user