stage main+sub exports on disk instead of /tmp/cache

A main+sub export stages a full copy of itself before the final file is written. That copy went to /tmp/cache, so a long export outgrew the tmpfs and failed with no space left on device. Staged runs are now written to the exports directory, and startup removes any left behind by a killed export.
This commit is contained in:
Josh Hawkins
2026-10-09 06:45:57 -05:00
parent 71445231f6
commit c89e77b332
3 changed files with 21 additions and 2 deletions
+5 -1
View File
@@ -15,7 +15,7 @@ from peewee import DoesNotExist
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera.record import ChaptersEnum from frigate.config.camera.record import ChaptersEnum
from frigate.const import UPDATE_JOB_STATE from frigate.const import EXPORT_DIR, UPDATE_JOB_STATE
from frigate.jobs.job import Job from frigate.jobs.job import Job
from frigate.models import Export from frigate.models import Export
from frigate.record.export import ( from frigate.record.export import (
@@ -415,6 +415,10 @@ def reap_stale_exports() -> None:
this in a try/except. A failure on a single row will not stop the rest this in a try/except. A failure on a single row will not stop the rest
of the sweep, and a failure in the top-level query will log and return. of the sweep, and a failure in the top-level query will log and return.
""" """
# staged stream runs live on disk, so a killed export leaves them behind
for staged in Path(EXPORT_DIR).glob("export_stage_*"):
staged.unlink(missing_ok=True)
try: try:
stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712 stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712
except Exception: except Exception:
+1 -1
View File
@@ -559,7 +559,7 @@ class RecordingExporter(threading.Thread):
) )
def _staged_run_path(self, index: int) -> str: def _staged_run_path(self, index: int) -> str:
return os.path.join(CACHE_DIR, f"export_stage_{self.export_id}_{index}.mp4") return os.path.join(EXPORT_DIR, f"export_stage_{self.export_id}_{index}.mp4")
def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None: def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None:
"""Probe one recording from a run for its resolution. """Probe one recording from a run for its resolution.
+15
View File
@@ -453,6 +453,21 @@ class TestHttpExport(BaseTestHttp):
assert unchanged.name == "front door export" assert unchanged.name == "front door export"
assert unchanged.video_path == video assert unchanged.video_path == video
def test_reap_stale_exports_removes_staged_runs(self):
with tempfile.TemporaryDirectory() as tmpdir:
staged = os.path.join(tmpdir, "export_stage_front_door_abc_0.mp4")
finished = os.path.join(tmpdir, "front_door_export.mp4")
for path in (staged, finished):
with open(path, "w") as handle:
handle.write("video")
with patch("frigate.jobs.export.EXPORT_DIR", tmpdir):
reap_stale_exports()
assert not os.path.exists(staged)
assert os.path.exists(finished)
def test_reap_stale_exports_deletes_rows_with_no_file(self): def test_reap_stale_exports_deletes_rows_with_no_file(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
stale_video = os.path.join(tmpdir, "stale.mp4") stale_video = os.path.join(tmpdir, "stale.mp4")