Skip to content

Adding quota to only sweep items based on disk usage - #305

Open
JamesWRC wants to merge 11 commits into
jon4hz:mainfrom
JamesWRC:feature/sweep_until_quota
Open

Adding quota to only sweep items based on disk usage#305
JamesWRC wants to merge 11 commits into
jon4hz:mainfrom
JamesWRC:feature/sweep_until_quota

Conversation

@JamesWRC

Copy link
Copy Markdown

Why

Myself and another person (see #158) are keen for this feature. Making it the ideal library management tool.

This filters control which content is eligible for deletion, but nothing previously controlled how much gets queued at once. On a large shared disk this can result in far more content being scheduled than the current disk pressure warrants. These new options let you express a target disk state and have jellysweep stop marking items once that target would be reached.

How it works

applySweepUntilLimit runs after the normal filter chain, before items are written to the database:

  1. Reads live disk stats via gopsutil/disk for each library's folder paths.
  2. Calculates bytes that need to be freed to reach the configured target.
  3. Subtracts bytes already pending deletion in the database (queued from previous runs, not yet deleted) so they aren't double-counted.
  4. Walks the filtered item list in order, including items until the budget is exhausted.

NOTE:

  • Shared mounts are handled correctly. If multiple libraries (e.g. Movies + TV Shows) sit on the same filesystem, they share a single budget keyed by the partition's total size. The most aggressive target across those libraries wins, and all libraries draw from the same pool, storage is not freed multiple times per library.
  • Tested on my own library (in dry run mode) and seems to be working well. My library is all on a single mount, so multiple mounts/volumes havent been tested but id imagine it would work fine. Can test the the image i have built jameswrc/jellysweep. All other filters and logic like 'Keep'ing an item marked for cleaning up, will grab the next lot of media to satisfy the quota on the next sweep.
  • All other tests have passed.
  • All code changes have been done by Claud Sonnet 4.6. All testing has been done manually and the outcome of the sweep_until_percent_used and sweep_until_gb_free values have been validated by me, and is as i expect.

Some other notes;
df reports usage as used / (used + available), not used / total — because the kernel reserves some blocks for root that are never shown as usable to normal processes. This implementation uses diskUsed + diskFree (where diskFree = Bavail via gopsutil) as the denominator, so 1sweep_until_percent_used: 67` means precisely the 67% you see in df output.

Copilot AI review requested due to automatic review settings March 25, 2026 11:47
@JamesWRC

JamesWRC commented Mar 25, 2026

Copy link
Copy Markdown
Author

Latest tests (via jameswrc/jellysweep image):

df (1K blocks) when testing: 43560266240 27773009416 13589571704 68% /mnt/media

Currently 68% full

sweep_until_percent_used: 65

Cleanup Mode Keep Count Est. GB Freed (via stats dashboard) Item Count
all 1 991 13
keep_episodes 10 1200 13
keep_seasons 1 1100 5

sweep_until_percent_used: 60

Cleanup Mode Keep Count Est. GB Freed (via stats dashboard) Item Count
all 1 ~3000 45
keep_episodes 10 ~3700 79
keep_seasons 1 ~3600 7

sweep_until_gb_free: 14000

Cleanup Mode Keep Count Est. GB Freed (via stats dashboard) Item Count
all 10 95 4
keep_episodes 10 681 11
keep_seasons 1 613 3

sweep_until_gb_free: 15500

Cleanup Mode Keep Count Est. GB Freed (via stats dashboard) Item Count
all 10 ~1700 17
keep_episodes 10 ~1800 17
keep_seasons 1 ~1900 11

@JamesWRC JamesWRC changed the title New impl Adding quota to only sweep items based on disk usage Mar 25, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds “sweep-until” caps to prevent a single cleanup run from queueing more deletions than needed to reach a target disk state, complementing the existing disk-usage-threshold-based cleanup behavior.

Changes:

  • Introduces sweep_until_gb_free and sweep_until_percent_used per-library config options and applies them during the mark-for-deletion phase.
  • Computes live disk usage for library folders (via gopsutil) and limits newly marked items based on estimated bytes freed.
  • Documents the new behavior and configuration in the README.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.

File Description
internal/engine/sweep_until.go New sweep-until limiting logic based on disk usage stats and estimated freed bytes.
internal/engine/engine.go Stores library folder paths and applies sweep-until limits after filters.
internal/config/config.go Adds two new cleanup config fields for sweep-until targets.
README.md Documents sweep-until options and adds a new “Storage Remaining Example” section.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/engine/sweep_until.go Outdated
Comment thread internal/engine/sweep_until.go Outdated
Comment thread internal/engine/sweep_until.go Outdated
Comment thread internal/engine/sweep_until.go Outdated
Comment thread internal/engine/sweep_until.go Outdated
Comment thread internal/engine/sweep_until.go Outdated
Comment on lines +220 to +234
sorted := make([]arr.MediaItem, len(entry.items))
copy(sorted, entry.items)
sort.Slice(sorted, func(i, j int) bool {
return estimateFreedSize(sorted[i], cleanupMode, keepCount) >
estimateFreedSize(sorted[j], cleanupMode, keepCount)
})

var newlyAccumulated int64
included := 0
for _, item := range sorted {
if stats.isTargetMet(libraryConfig, alreadyAccumulated+newlyAccumulated) {
break
}
newlyAccumulated += estimateFreedSize(item, cleanupMode, keepCount)
result = append(result, item)

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sort.Slice’s comparator calls estimateFreedSize repeatedly (twice per comparison). For large libraries this can get expensive, especially for TV series where estimateFreedSize walks seasons. Consider precomputing the estimated freed bytes once per item (decorate-sort-undecorate) and sorting on the cached values.

Suggested change
sorted := make([]arr.MediaItem, len(entry.items))
copy(sorted, entry.items)
sort.Slice(sorted, func(i, j int) bool {
return estimateFreedSize(sorted[i], cleanupMode, keepCount) >
estimateFreedSize(sorted[j], cleanupMode, keepCount)
})
var newlyAccumulated int64
included := 0
for _, item := range sorted {
if stats.isTargetMet(libraryConfig, alreadyAccumulated+newlyAccumulated) {
break
}
newlyAccumulated += estimateFreedSize(item, cleanupMode, keepCount)
result = append(result, item)
type sweepCandidate struct {
item arr.MediaItem
freed int64
}
sorted := make([]sweepCandidate, len(entry.items))
for i, it := range entry.items {
sorted[i] = sweepCandidate{
item: it,
freed: estimateFreedSize(it, cleanupMode, keepCount),
}
}
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].freed > sorted[j].freed
})
var newlyAccumulated int64
included := 0
for _, cand := range sorted {
if stats.isTargetMet(libraryConfig, alreadyAccumulated+newlyAccumulated) {
break
}
newlyAccumulated += cand.freed
result = append(result, cand.item)

Copilot uses AI. Check for mistakes.
Comment thread README.md Outdated
JamesWRC and others added 4 commits March 25, 2026 22:53
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@JamesWRC JamesWRC mentioned this pull request Mar 25, 2026
@JamesWRC

Copy link
Copy Markdown
Author

This is ready for review @jon4hz

@jon4hz

jon4hz commented Mar 25, 2026

Copy link
Copy Markdown
Owner

Hey @JamesWRC,

Thanks for the contribution. I'll try to give you a review soonish.

Comment thread internal/engine/sweep_until.go Outdated
// filesystems would need to match on both capacity and inode count to collide,
// which is effectively impossible in practice.
func (s *sweepDiskStats) mountKey() string {
return fmt.Sprintf("fs:%d:%d", s.totalBytes, s.totalInodes)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there actually a guarantee that a system can't have two disks with the same amount of bytes and inodes?

Using this as indicator to check if a library actually has the same underlying storage seems very random and unreliable to me.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I think this PR might not take more complicated filesystem layouts into account. For example, different TV shows (especially large ones) could have their own disk.

How would you handle this?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cheers for reviewing.
Yeah youre right it does use the same free/used bytes.
Did not think about setups like 1 show per mount.

Will look into these soon.

@jon4hz

jon4hz commented Mar 25, 2026

Copy link
Copy Markdown
Owner

Please correct me if I'm wrong but with the current implementation the deletion just stops if certain storage targets are triggered, right?

How is that reflected on the items that were marked for deletion but actually weren't deleted because the storage target was reached?

Will those items be deleted immediately, once the storage fills up to a certain percentage? Or are they removed from the tracking db and must be picked up again to go through the entire deletion process incl. grace period, etc.

Option 1 feels like it makes deletion much less predictable which is something that I definitely want to avoid and option 2 will generate more noise as people will be notified over and over that their requested media is scheduled for deletion until it's actually deleted once.

Neither of those options seem great to me.

@JamesWRC

Copy link
Copy Markdown
Author

Please correct me if I'm wrong but with the current implementation the deletion just stops if certain storage targets are triggered, right?

How is that reflected on the items that were marked for deletion but actually weren't deleted because the storage target was reached?

Will those items be deleted immediately, once the storage fills up to a certain percentage? Or are they removed from the tracking db and must be picked up again to go through the entire deletion process incl. grace period, etc.

Option 1 feels like it makes deletion much less predictable which is something that I definitely want to avoid and option 2 will generate more noise as people will be notified over and over that their requested media is scheduled for deletion until it's actually deleted once.

Neither of those options seem great to me.

Please correct me if I'm wrong but with the current implementation the deletion just stops if certain storage targets are triggered, right?

How is that reflected on the items that were marked for deletion but actually weren't deleted because the storage target was reached?

Will those items be deleted immediately, once the storage fills up to a certain percentage? Or are they removed from the tracking db and must be picked up again to go through the entire deletion process incl. grace period, etc.

Option 1 feels like it makes deletion much less predictable which is something that I definitely want to avoid and option 2 will generate more noise as people will be notified over and over that their requested media is scheduled for deletion until it's actually deleted once.

Neither of those options seem great to me.

This acts as an additional filter in the candidate pipeline. Only items within the diskspace quota are saved to the database, items cut off by the limit are simply excluded from that run and re-evaluated on the next scheduled sweep. The normal deletion lifecycle (grace period, notifications, user keep requests) applies as usual, this feature only controls which items become eligible for deletion.

Introduce named sweep-until quota groups to cap deletions across shared storage pools. Config additions: Config.SweepUntilQuotaGroups map and QuotaGroupConfig (percent_used, gb_free), plus Filter.SweepUntilQuotaGroup to opt libraries into a group; removed per-library sweep_until_* fields. Validation now checks quota group definitions and references and warns about unused groups.

Engine changes: aggregate disk usage across unique filesystems for each quota group (deduplicating bind-mounts/volumes), seed group budgets from pending DB items, and enforce group-wide sweep-until targets while preserving original filter ordering. Added robust error handling/logging (skip group on stat failures) and updated helper functions (mount key, disk stat aggregation, pending seeds). README updated with documentation, examples, and guidance for quota groups and multi-disk behavior.
@JamesWRC

JamesWRC commented Mar 26, 2026

Copy link
Copy Markdown
Author

The previous implementation was a disk based quota. Now its a per library quota, where all mounts are part of the specified group are summed and used in the quota calculation.
In the event a show is on a unique special mount, if its part of a library with a configured quote group, the mount will be added into the calculation of the quota. All filtering and ordering of items to be sweeped are the same.
This implementation should cater for more general cases even using NFS etc. If there is a show or library that shouldnt be part of the quota, then the user should not add a quota group for this library or use existing exclusion features.

@jon4hz

jon4hz commented Mar 26, 2026

Copy link
Copy Markdown
Owner

This acts as an additional filter in the candidate pipeline. Only items within the diskspace quota are saved to the database, items cut off by the limit are simply excluded from that run and re-evaluated on the next scheduled sweep. The normal deletion lifecycle (grace period, notifications, user keep requests) applies as usual, this feature only controls which items become eligible for deletion.

Ah I see, thanks for the clarification.

In this case, the filtering processes shouldn't take place in the engine though. If it acts as a filter, it should be its own package that implement the filterer interface, like all the other filters here.

Re implemented sweep_until as a filter. As that is what it is, so there are now minimal changed to the engine.

Updated Filter to be extendable, so filters can get a reference to the engine instance.

Added largest_first so that if users prefer (i do), If an item is eligible for deletion, delete the biggest item first - as it leads to less media being removed.
@JamesWRC

Copy link
Copy Markdown
Author

Latest tests (via jameswrc/jellysweep image):

df (1K blocks) when testing: 43560266240 27810515336 13552065784 68% /mnt/media

Currently 68% full

sweep_until_percent_used: 65

Cleanup Mode Keep Count Est. GB Freed (via stats dashboard) Item Count
all 1 ~1100 32
keep_episodes 10 ~1500 31
keep_seasons 1 ~1400 21

sweep_until_gb_free: 14000

Cleanup Mode Keep Count Est. GB Freed (via stats dashboard) Item Count
all 10 142 10
keep_episodes 10 246 11
keep_seasons 1 201 4

sweep_until_gb_free: 15500

Cleanup Mode Keep Count Est. GB Freed (via stats dashboard) Item Count
all 10 ~1700 45
keep_episodes 10 ~2200 50
keep_seasons 1 ~2200 40

With largest first on

Latest tests (via jameswrc/jellysweep image):

df (1K blocks) when testing: 43560266240 27810515336 13552065784 68% /mnt/media

Currently 68% full

sweep_until_percent_used: 65

Cleanup Mode Keep Count Est. GB Freed (via stats dashboard) Item Count
all 1 ~1000 7
keep_episodes 10 ~1100 9
keep_seasons 1 ~1200 10

sweep_until_gb_free: 14000

Cleanup Mode Keep Count Est. GB Freed (via stats dashboard) Item Count
all 10 231 1
keep_episodes 10 231 1
keep_seasons 1 231 1

sweep_until_gb_free: 15500

Cleanup Mode Keep Count Est. GB Freed (via stats dashboard) Item Count
all 10 ~1700 20
keep_episodes 10 ~2000 31
keep_seasons 1 ~2000 32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/filter/sweep_until_filter/filter.go Outdated
Comment thread internal/filter/sweep_until_filter/filter.go Outdated
Comment thread internal/filter/sweep_until_filter/filter.go Outdated
Comment thread internal/engine/engine.go Outdated
@JamesWRC
JamesWRC requested a review from jon4hz March 27, 2026 23:04
JamesWRC and others added 2 commits June 1, 2026 18:30
…aths

Fixes from a deep review of the sweep_until_quota feature:

Budget accounting:
- Count freeable size (keep-mode aware, via Sonarr per-season stats) instead
  of full series size; store it on new Media.FreeableSize (*int64, nil=unknown)
  so pending and new items are accounted identically
- Store the quota group on new Media.QuotaGroup so the budget seed survives
  library renames and config regrouping
- Stamp both fields in every row-creation path via stampQuotaAccounting,
  using GetKeepCount() to match the deletion logic
- Seed dedupes rows sharing a JellyfinID; re-added items (new arr ID, same
  Jellyfin ID) are excluded instead of double-charging the budget
- DB error during seeding withholds all grouped items instead of treating
  pending items as zero

Filesystem identity:
- ZFS datasets keyed by pool: used bytes summed per dataset, pool free space
  counted once (a dataset's statfs reports its own used but pool-wide free)
- Partial disk.Usage failure marks the group broken instead of sweeping
  against incomplete stats
- Symlinks resolved before mount matching; overmounted mountpoints resolve
  last-wins; st_dev verification splits false key collisions and replaces the
  unstable Total+Free fallback key on unix

Engine integration:
- Jellyfin per-library fetch failures no longer wipe pending DB rows (and
  with them the budget seed) as "missing in Jellyfin"
- removeRecentlyPlayedItems runs before marking so doomed rows don't consume
  budget in the same run

Config & ordering:
- Quota group names are case-insensitive (viper lowercases map keys)
- Default order now preserves the order items were reported eligible;
  alphabetical behavior available as order: title; deprecated largest_first
  flag removed (never shipped)
- Disabled libraries excluded from group mapping

Test suite: injectable disk/DB seams, 50+ tests covering Apply end-to-end,
multi-run lifecycle, mergerfs/ZFS layouts, failure modes, and config
validation; runs locally with go test ./... -race (no real mounts needed).

README: documents the broken-group fail-safe, queue-vs-delete timing,
ZFS semantics, env-var limitations, and fixes stale/contradictory text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants