Files
RenewCMS/features/media_purge_scheduler.feature

287 lines
15 KiB
Gherkin

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