Skip to content

storage: percentage / free-space based retention (min_free_space) - #1429

Open
fluffyspace wants to merge 1 commit into
roflcoopter:devfrom
fluffyspace:storage-min-free-space-retention
Open

storage: percentage / free-space based retention (min_free_space)#1429
fluffyspace wants to merge 1 commit into
roflcoopter:devfrom
fluffyspace:storage-min-free-space-retention

Conversation

@fluffyspace

Copy link
Copy Markdown
Contributor

Summary

Adds an optional min_free_space retention rule to the storage component so a tier can be told to "keep at least X% (or X GB) of the filesystem free", evaluated live, instead of (or in addition to) the existing absolute per-camera max_size byte cap.

Motivation

Today a tier's size limit is only expressible as absolute bytes, enforced per camera per tier (max_size: { gb, mb }). That has two sharp edges on real deployments:

  1. It doesn't compose with the number of cameras. Operators reason about max_size as a total, but the true worst case is per-camera cap × number of cameras. On a shared disk it's easy to under-provision and fill the volume.
  2. It's blind to the rest of the disk. When the tier's path shares a filesystem with other data, a byte cap can't express "always leave headroom on the volume" — which is usually what you actually want on a shared disk.

What this does

storage:
  recorder:
    tiers:
      - path: /recordings
        continuous:
          max_age: { days: 7 }
          min_free_space: { percent: 15 }   # or { gb: 40 }

When set, the oldest files on that tier across all cameras on the filesystem are evicted whenever live free space (shutil.disk_usage) drops below the floor. It layers on top of max_age/max_size — a file is removed when any configured rule fires. Accepted on the recorder continuous/events blocks and on snapshot/timelapse tiers.

Fully backward compatible: unset ⇒ floor 0 ⇒ the previous size/age-only behaviour, exactly. No DB/schema migration.

Implementation

Free space is a filesystem-global property while the existing tier check is per-camera, so this is a dedicated all-cameras eviction pass rather than a change to the per-camera path:

  • util.calculate_free_space_floor — resolves percent-of-total + absolute gb/mb to a byte floor (the largest wins); total read once via disk_usage.
  • check_tier.get_files_to_delete_for_free_space — pure-numpy oldest-first selection covering the deficit, with a write-protection window guarding freshly written segments (active HLS).
  • check_tier.load_free_space_candidates — loads a tier's files across all cameras for one subcategory.
  • check_tier.Worker.free_space_evict — piggybacked on each camera's check_tier, but serialized + throttled per filesystem so N concurrent camera checks cause one eviction pass, not N. Reuses the existing delete_file path.
  • DataItem carries min_free_bytes + tier_fs_path (defaults keep it inert); tier handlers resolve the floor once at init and plumb it through.

Design notes

  • Deletion, never move — only deletion returns space to a disk, so this is meant for the terminal tier of a filesystem.
  • btrfs-safe — selection is a single size-summed batch (no per-row disk_usage re-poll), so a filesystem whose accounting lags behind unlinks isn't over-evicted.
  • Known limitation — under genuine disk pressure this can evict old continuous segments that belong to an event recording (that recording's playback then 404s). It's a last-resort backstop; happy to add orphan-only (recording-aware) protection if you'd prefer that as the default.

Tests

  • test_free_space.py — compute selection (oldest-first, exact boundaries, min-age protection, empty/no-deficit).
  • test_util.pycalculate_free_space_floor (percent / absolute / max-of-several / stat-failure / unset).
  • test_config.py — schema accept/reject for min_free_space (percent (0,100]) + defaults presence on continuous/events/snapshots.

Retention limits can currently only be expressed as absolute bytes per camera
per tier (max_size). On a shared disk that composes badly: the real ceiling is
per-camera cap x number of cameras, and a byte cap cannot express "always
leave headroom on the volume".

Add an optional `min_free_space: {percent: N | gb: N | mb: N}` key to each tier
(recorder continuous/events blocks and snapshot/timelapse tiers). When set, the
oldest files on that tier -- across ALL cameras on the filesystem -- are
evicted whenever live free space (shutil.disk_usage) drops below the floor. It
layers on top of max_age/max_size (evict when ANY rule fires) and is fully
backward compatible: unset => floor 0 => previous behaviour exactly. No DB
migration.

Free space is a filesystem-global property while the existing tier check is
per-camera, so this is a dedicated all-cameras eviction pass:

- const/config: `min_free_space` schema (percent in (0,100], gb/mb), attached
  to TIER_SCHEMA_BASE so it applies to continuous/events/snapshots/timelapse.
- util.calculate_free_space_floor: resolves percent-of-total + absolute gb/mb
  to a byte floor (max of them); filesystem total read via disk_usage.
- check_tier.get_files_to_delete_for_free_space: pure-numpy oldest-first
  selection covering a deficit, with a write-protection window guarding freshly
  written segments (active HLS).
- check_tier.load_free_space_candidates: loads a tier's files across all
  cameras for one subcategory.
- check_tier.Worker.free_space_evict: piggybacked on each camera's check_tier
  but serialized + throttled per filesystem so N concurrent camera checks cause
  one eviction pass, not N. Reuses the existing delete_file path.
- DataItem carries min_free_bytes + tier_fs_path (defaults keep it inert).
- tier handlers resolve the floor once at init and plumb it through.

Deletion (never move) is used because only deletion returns space to a disk;
the feature is meant for the terminal tier of a filesystem. Selection is a
single size-summed batch (no per-row disk_usage re-poll) so a filesystem whose
accounting lags behind unlinks (e.g. btrfs) is not over-evicted.

Tests: free-space compute selection (oldest-first, boundaries, min-age
protection), calculate_free_space_floor (percent/absolute/max/stat-failure),
and schema accept/reject for min_free_space.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@netlify

netlify Bot commented Jul 25, 2026

Copy link
Copy Markdown

Deploy Preview for viseron canceled.

Name Link
🔨 Latest commit ee8fe37
🔍 Latest deploy log https://app.netlify.com/projects/viseron/deploys/6a65169c70ef9e0008cf3e7b

@roflcoopter

Copy link
Copy Markdown
Owner

Thank you, this seems like a great addition. I am preparing for a new release atm, will look into this when that is done

DESC_CHECK_INTERVAL_SECONDS = "Seconds between checks for files to move/delete."
DESC_MIN_SIZE = "Minimum size of files to keep in this tier."
DESC_MAX_SIZE = "Maximum size of files to keep in this tier."
DESC_MIN_FREE_SPACE = (

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.

This description is a little too chatty, try to shorten it if possible

)


def load_free_space_candidates(

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.

Loading all files for a tier could potentially cause memory issues, maybe this one would suite better as purely an SQL query instead of loading and using numpy.

What do you think about that?

The reason why the normal eviction uses loading + numpy is because a similar SQL query became extremely complicated and slow, using numpy was the better choice there to get better control of the CPU usage using throttling etc

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.

2 participants