From c89e77b3329b5a13c94cd139913b1f615ebb13aa Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 9 Oct 2026 06:45:57 -0500 Subject: [PATCH] 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. --- frigate/jobs/export.py | 6 +++++- frigate/record/export.py | 2 +- frigate/test/http_api/test_http_export.py | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/frigate/jobs/export.py b/frigate/jobs/export.py index e1cf09060e..abc2e5837f 100644 --- a/frigate/jobs/export.py +++ b/frigate/jobs/export.py @@ -15,7 +15,7 @@ from peewee import DoesNotExist from frigate.comms.inter_process import InterProcessRequestor from frigate.config import FrigateConfig 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.models import Export 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 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: stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712 except Exception: diff --git a/frigate/record/export.py b/frigate/record/export.py index 7455d56b05..4446dbf5f3 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -559,7 +559,7 @@ class RecordingExporter(threading.Thread): ) 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: """Probe one recording from a run for its resolution. diff --git a/frigate/test/http_api/test_http_export.py b/frigate/test/http_api/test_http_export.py index 51f10c7abb..58c48f4056 100644 --- a/frigate/test/http_api/test_http_export.py +++ b/frigate/test/http_api/test_http_export.py @@ -453,6 +453,21 @@ class TestHttpExport(BaseTestHttp): assert unchanged.name == "front door export" 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): with tempfile.TemporaryDirectory() as tmpdir: stale_video = os.path.join(tmpdir, "stale.mp4")