Repository navigation
Retention: cron schedules in a time zone, superseded/unlisted/count triggers, version and tag patterns, and format retention hooks - #721
Merged
Conversation
…n hooks Policies of every shape (package, raw file, image) take a 5-field cron `schedule` in an IANA `schedule_timezone` as an alternative to `min_run_interval_seconds`, and `max_run_seconds`. The worker computes the next run from the schedule (croner, Vixie-cron rules across DST; chrono-tz compiled in), a saved schedule waits for its next time, Run now still runs at once, and a run that reaches its time limit stops before its next batch and leaves the rest to the next run (`deferred` in the run summary). Package rules gain the triggers `prerelease_superseded_for_days`, `unlisted_for_days` (with `component.unlisted_at`, kept by a trigger on every unlist path; versions unlisted before take `updated_at`) and `max_versions_per_package` (hosted and proxy), and the filters `version_pattern`, `exclude_version_pattern`, `selector` (a content selector, which cannot be deleted while a rule names it) and `release_type: snapshots`. Image rules gain `tag_pattern`, `exclude_tag_pattern` and globs in `protected_tags`. Protections win over every trigger. The format contract gets `version_class` and `retention_partition`, which the evaluator asks for classes and keep-N partitions. The admin API validates every new field, the preview lists a schedule's next runs and each decision's reason, the Retention screen edits all of it, and docs/retention.md lists every rule with examples. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012e6DJc3No8xu6rxoMc3pFJ
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Oct 31, 2026
Member
Author
|
The merge queue removed this PR because the
So this is a timing-dependent test that failed once, not something this PR can fix. No change is pushed for it, and the PR can go back into the queue as it is. Generated by Claude Code |
schmitch
added a commit
that referenced
this pull request
Oct 31, 2026
…e apt branch - The apt migration is 0012 now, after main's 0010_raw_import and 0011_retention_unlisted_at. - Retention asks the plugin's `version_class` (#721): apt answers `Release` for every version, and the host's `Format::is_prerelease` of this branch is dropped. `Descriptor.prereleases` stays for the search filter, which still reads the `-` (browse lists every apt version). - The raw import's file store write sets `immutable: false`. - docs: the Signed indexes, Format options and Retention sections side by side; apt's package ids already count keep-N per architecture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012e6DJbxCkG53eCuDWqGGFy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #185
Retention policies can now run on a cron schedule in a time zone with a time limit per run, package rules get three new triggers and three new filters, image rules get tag patterns and glob protections, and the format contract gets the two retention hooks (version class, keep-N partition) that apt and Maven need. The admin API validates every new field, the Retention screen edits all of it, and
docs/retention.mdnow lists every rule with examples.1. Cron schedules (package, raw file and image policies)
model.rs,files.rs,images.rs):schedule: a 5-field cron pattern (0 3 * * *;@dailyand the other nicknames work). Seconds and years are refused.schedule_timezone: an IANA zone (Europe/Berlin). UTC when absent. Refused without aschedule.max_run_seconds: from 60 s to a week.min_run_interval_secondsstays the default way to schedule, and a policy with ascheduleignores it.crates/core/src/retention/schedule.rs: parsing, validation,next_after/next_runs, and reading these fields from a stored policy of any shape.worker.rs):next_run_atis the schedule's first time at least the one-minute floor after the run started. The three per-shape interval computations are now onenext_run_at, which a failed run uses too.max_run_seconds.apply,apply_filesandapply_imagescheck aRunClockbefore each batch or eviction.RunSummary.deferred. The next run re-evaluates and goes on with the rest.PUT …/retention):write_policy_at), so it does not run the moment it is saved.last start + interval(never before now).finish_run/fail_runtake the stored policy the run evaluated. When the stored policy is a different one by then, they keep the next run that the new policy's write set. Without this, an old interval'snow + 6hcould land a run in working hours.2. New triggers and filters
Package rules (
evaluate.rs):prerelease_superseded_for_days: selects a prerelease (or snapshot) once its package has had a listed release that orders after it for N days. For SemVer, that means a release with the same or a higher base version.superseded_by.unlisted_for_days: selects a version that has been unlisted for N days, read from the newcomponent.unlisted_at.action: hard_delete;unlistis refused, because unlisting an unlisted version does nothing.max_versions_per_package: a pure count. It selects what is beyond the newest N versions, listed or not, of the rule's pool in its partition.proxy_cachedrules (cached versions).rank.version_pattern/exclude_version_pattern: globset globs on the whole normalized version, ignoring case, with{a,b}alternation.selector: an existing content selector, by name, matched like a grant scoped by it.release_type: snapshots.selectorhandling:retention::resolve_selectors).PUT/preview refuse a policy that names a missing selector (400).DELETE /admin/content-selectors/{id}answers 409 and names the repositories while a retention rule uses the selector (retention_dao::repositories_using_selector, ajsonb_path_existsquery).RuleFilter). A glob that does not compile, or a selector that is not resolved, makes the rule admit nothing.Image rules (
images.rs):tag_pattern/exclude_tag_patternchoose which tags a tag rule looks at, andkeep_last_n_tagscounts only those.protected_tagstakes globs (release-*) as well as names, in the rules and in the size budget's protections.Protections still win over every new trigger. A version that any rule's
keep_last_n/keep_n_most_downloadedkeeps is never selected, in the same rule or another one.Validation (
deny_unknown_fields, as before):unlisted_for_dayson anunlistrule, orprerelease_superseded_for_dayson areleasesrule.proxy_cachedrules, and for an invalid cron pattern, time zone ormax_run_seconds.3. Format-specific rules: plugin contract hooks
FormatPlugin::version_class(version) -> VersionClass(Release/Prerelease/Snapshot, inforge-plugin-api).-makes a prerelease).release_typeand the supersession trigger use it.FormatPlugin::retention_partition(id, version) -> Option<String>: the key that keep-N counts within (keep_last_n,keep_n_most_downloaded,max_versions_per_package).None: the whole package is one partition.forge_core::Formatand forwarded byPluginFormat.main, and the issue's criteria say their rules land with them.format_hooks_partition_keep_n_and_class_snapshotsdrives the generic rules with a Maven-like test format:-SNAPSHOTversion;groupId:artifactId.docs/format-plugins.mdhas a new "Retention" section spelling this out.main, which already has path-prefix rules (path_prefix), keep-N files per directory (keep_newest_per_directory) and the age/download triggers, as Raw (generic file) repositories: hosted, proxy and group, Nexus-style #42 specifies. Raw policies only gain the schedule fields here.4. Schema
0011_retention_unlisted_at.sql: addscomponent.unlisted_at, maintained by aBEFORE INSERT OR UPDATE OF listed, unlisted_attrigger:updated_at. Every unlist path setsupdated_at = now(), so the default is never earlier than the real unlisting. A version changed after it was unlisted counts from that later change, which only defers its deletion.185.upgrade.mdsays so..sqlx:forge-dbagainst Postgres 18 migrated through 0011 (SQLX_OFFLINE_DIR);finish_run,fail_run, the two candidate loaders (now withunlisted_at),write_policy_at's update andrepositories_using_selector;API
PUT /admin/repositories/{name}/retentionaccepts and validates every field above, and sets the next run for a schedule.POST …/retention/preview:superseded_by,prerelease_superseded_for_days,unlisted_for_days,max_versions_per_package,rank);next_runs(its next 5 times, UTC).last_run.last_run_summary) gainsdeferred.DELETE /admin/content-selectors/{id}: 409 while a retention rule names the selector.Web UI (Repositories → repository → Retention)
max_run_secondswith a unit.0 3 * * * (Europe/Berlin); a run stops after 1 hour) and, after a run that hit its limit, "Left for the next run".Docs
docs/retention.md: now the full retention reference (title "Retention" in SUMMARY/README):max_run_seconds, missed times;docs/docker.md: tag patterns, glob protected tags, schedule fields.docs/raw.md: schedule fields, and links updated.docs/npm.md: links updated.docs/format-plugins.md: a new "Retention" section with the hooks and what Maven and apt need. The "Prerelease is contains a-" limitation now applies only to the search filter.docs/changes/2.2.0/185.feature.mdand185.upgrade.md(theunlisted_atdefault, and the 409 on selector deletion).Tests
Unit tests (
forge-core):schedule.rs:0 3 * * *in Europe/Berlin across the 2026-03-29 spring change and the 2026-10-25 autumn change stays at 03:00 local (02:00 → 01:00 → 02:00 UTC);30 2 * * *in the skipped hour runs once, at 03:00 CEST;30 2 * * *in the repeated hour runs once, at its first occurrence;RunClockfirst-step rule.evaluate.rs, one or more tests per new trigger and filter:superseded_by;unlisted_for_days, including an unknown unlisting time, and the two-step unlist-then-delete policy;max_versions_per_package: pure count with ranks, unlisted versions counted, counting within the rule's pool and ANDed with age;model.rs: the new fields round-trip, absent fields are not serialized, unknown fields are refused, every new validation error, hosted-only triggers refused on proxies, schedule validation.images.rs: tag patterns and keep-N over the pool, glob protected tags in rules and the budget, validation.files.rs: schedule validation.Plugin contract (
forge-plugin,retention_hooks_reach_the_host): the defaults, and a plugin's own hooks forwarded throughPluginFormat.DB (
crates/db/tests):unlisting_records_when_on_every_path: insert unlisted, client unlist/relist, re-unlist keeps the time, backdating, retention's update, candidates carryunlisted_at.a_saved_schedule_sets_the_next_run_and_a_stale_run_keeps_it: the guard infinish_run/fail_run.deferredserialization.repositories_using_selector.migrations.rsversions_unlisted_before_the_upgrade_count_from_their_last_change: migrates to 0007, seeds rows, migrates on, checks the default and then the trigger.Core integration (
crates/core/tests):retention_applya_run_past_its_deadline_stops_and_the_next_one_resumes: three packages with the deadline already passed take three runs, each deleting one and deferring the rest; with a far deadline, one run applies everything.retention_worker:next_run_atis the next 03:00 Berlin;unlisted_for_daysand supersession end to end, with the audit reasons;Server (
crates/server/tests/admin_retention.rs):schedules_triggers_and_selectors_are_validated_and_stored:preview_shows_the_new_triggers_decisions_and_the_next_runs: supersession and unlisted decisions with their reasons, 5next_runsat 02:30/03:00 Berlin, the count trigger with a protection from another rule.image_rules_take_tag_patterns_and_glob_protected_tags.Web:
RetentionPolicyForm.test.ts: schedule, time limit and new rule fields round-trip; interval switch; cron and time-limit checks; new reasons;describeSchedule.retention.test.tsx: a scheduled policy loads into the form; the Runs panel shows the schedule and deferred count; the selector picker lists the selectors; editing the new fields sends them in the preview; the preview shows reasons and next runs; switching back to the interval.Checks run
CARGO_PROFILE_{DEV,TEST}_DEBUG=line-tables-only,SQLX_OFFLINE=true,CARGO_INCREMENTAL=0. The DB-backed tests ran against a Postgres 18 container (mirror.gcr.io/library/postgres:18) throughFORGE_TEST_DATABASE_URL, on a fresh database per run.cargo test -p forge-core --lib: 349 passed (114 of them retention).cargo test -p forge-core --test retention_apply --test retention_worker: 9 + 22 passed.cargo test -p forge-db --test retention: 19 passed.--test migrations: 4 passed.cargo test -p forge-plugin --lib: 10 passed.cargo test -p forge-server --test admin_retention: 17 passed.cargo test -p forge-server --test raw retention: 1 passed.--test registry retention: 5 passed. These are the image/file apply callers whose signature changed.cargo test -p forge-server --test admin_rbac: 15 passed (content selectors).cargo clippy -p forge-plugin-api -p forge-plugin -p forge-core -p forge-db -p forge-server --all-targets -- -D warnings: clean.cargo fmt --all -- --check: clean.cargo xtask changelog --check: "33 changelog fragment(s) under docs/changes, all for milestones after 2.1.0."cargo xtask docs --check: "16 page(s) of the site, every link resolves."pnpm install --frozen-lockfile && pnpm run tsc && pnpm run test && pnpm run build: tsc clean, 40 files / 538 tests passed, build ok.cargo sqlx prepare --check(no sqlx-cli here; the.sqlxentries were generated as described under Schema).Notes
Cargo.lockto 4.0.1, which is about a month old; 4.0.2 is 10 days old and only adds exports.DateTime<Tz>, the workspace's time type.croncrate needs 6–7 fields (seconds) and has no documented DST policy.saffronis unmaintained./usr/share/zoneinfo.jiff(already in the lock throughserde_with) would also do, but everything else, the DB rows included, is chrono.compare_versionsorders after the prerelease. For SemVer, NuGet and Maven ordering this is equivalent, and it needs no base-version hook. Snapshots count as superseded like prereleases.max_versions_per_packagecounts listed and unlisted versions (what is stored). It is also allowed on proxy rules.selectorreferences a content selector by name. Names cannot change. A missing selector fails closed, and deletion is refused while a rule uses it.max_run_secondskeeps such a run short.max_run_secondsstops and resumes a run": tested deterministically at the apply layer, which is where the stop happens. The worker computes the deadline from anInstantat claim time. The worker-level test only checks that a scheduled policy with a limit completes withdeferred: 0. A worker test that actually stops would need a clock hook, which I did not add. Files and images share the sameRunClock(unit-tested), without a DB-level deadline test of their own.prerelease) still reads the-. Only retention asksversion_class, anddocs/format-plugins.mdsays so.0011, as assigned. It only adds a column and a trigger, so it applies in any order with the open PRs'0008/0009.Generated by Claude Code