Compare commits

...

4 Commits

Author SHA1 Message Date
dependabot[bot]
fbab1dfd0b
Merge 8ea5eb6bd1 into d982b3a782 2026-06-22 03:10:37 +08:00
Daniel
d982b3a782
perf(util): use monotonic clock and bounded deque in EventsPerSecond (#23520)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* perf(util): use monotonic clock and bounded deque in EventsPerSecond

EventsPerSecond is updated on every captured frame, every detection and
every processed frame across all cameras and detectors. The previous
implementation derived timestamps from datetime.now().timestamp() (wall
clock), so an NTP or manual clock adjustment could skew the rolling-window
expiry; it also stored timestamps in a list and expired them with
del self._timestamps[0] (O(n) per removal) plus a periodic slice-copy to
cap growth.

Switch to time.monotonic() for the interval math (correct by construction
and immune to wall-clock jumps) and a collections.deque(maxlen=...) so
expiry is O(1) (popleft) and retention is bounded automatically. This
mirrors the deque-based expiry already used in video/ffmpeg.py and
watchdog.py. Observable output is unchanged.

Adds frigate/test/test_builtin.py covering rate calculation, window
expiry and the memory bound.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: drop test_timestamps_are_memory_bounded

It only asserted that deque(maxlen=) caps length, which is stdlib behavior
rather than something this change needs to verify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:38:41 -06:00
Josh Hawkins
d036061e3f
cache the preview_frames directory listing so concurrent per-camera frame requests share one scan instead of each re-listing the whole directory (#23526)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-06-20 14:56:05 -05:00
dependabot[bot]
8ea5eb6bd1
Bump i18next-http-backend from 3.0.1 to 3.0.5 in /web
Bumps [i18next-http-backend](https://github.com/i18next/i18next-http-backend) from 3.0.1 to 3.0.5.
- [Changelog](https://github.com/i18next/i18next-http-backend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-http-backend/compare/v3.0.1...v3.0.5)

---
updated-dependencies:
- dependency-name: i18next-http-backend
  dependency-version: 3.0.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-22 18:08:47 +00:00
5 changed files with 94 additions and 35 deletions

View File

@ -1,7 +1,9 @@
"""Preview apis."""
import bisect
import logging
import os
import threading
from datetime import datetime, timedelta, timezone
import pytz
@ -133,6 +135,32 @@ def preview_hour(
return preview_ts(camera_name, start_ts, end_ts, allowed_cameras)
# cache one sorted listing of the shared preview_frames dir
_preview_listing_lock = threading.Lock()
_preview_listing_cache: tuple[float, list[str]] = (-1.0, [])
def _get_preview_frame_listing(preview_dir: str) -> list[str]:
"""Return the sorted preview_frames listing, cached until the dir changes."""
global _preview_listing_cache
# mtime bumps when a frame is added or removed, invalidating the cache
mtime = os.stat(preview_dir).st_mtime
cached_mtime, files = _preview_listing_cache
if mtime == cached_mtime:
return files
with _preview_listing_lock:
# another thread may have refreshed the cache while we waited
cached_mtime, files = _preview_listing_cache
if mtime == cached_mtime:
return files
files = sorted(entry.name for entry in os.scandir(preview_dir))
_preview_listing_cache = (mtime, files)
return files
@router.get(
"/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames",
response_model=PreviewFramesResponse,
@ -149,23 +177,15 @@ def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: flo
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
camera_files = [
entry.name
for entry in os.scandir(preview_dir)
if entry.name.startswith(file_start)
files = _get_preview_frame_listing(preview_dir)
# a camera's frames form a contiguous slice of the sorted listing;
# bisect locates it without scanning the whole directory
left = bisect.bisect_left(files, start_file)
right = bisect.bisect_right(files, end_file)
selected_previews = [
file for file in files[left:right] if file.startswith(file_start)
]
camera_files.sort()
selected_previews = []
for file in camera_files:
if file < start_file:
continue
if file > end_file:
break
selected_previews.append(file)
return JSONResponse(
content=selected_previews,

View File

@ -0,0 +1,41 @@
"""Tests for frigate.util.builtin helpers."""
import unittest
from unittest.mock import patch
from frigate.util.builtin import EventsPerSecond
class TestEventsPerSecond(unittest.TestCase):
def test_eps_is_zero_before_any_events(self) -> None:
eps = EventsPerSecond()
with patch("frigate.util.builtin.time.monotonic", return_value=100.0):
self.assertEqual(eps.eps(), 0.0)
def test_eps_counts_events_in_window(self) -> None:
eps = EventsPerSecond(last_n_seconds=10)
clock = [1000.0]
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
eps.start()
# one event per second for five seconds
for _ in range(5):
clock[0] += 1.0
eps.update()
# five events over the five seconds since start
self.assertAlmostEqual(eps.eps(), 1.0)
def test_old_timestamps_expire_from_window(self) -> None:
eps = EventsPerSecond(last_n_seconds=10)
clock = [0.0]
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
eps.start()
for _ in range(10):
clock[0] += 1.0
eps.update()
# jump well past the window so every timestamp ages out
clock[0] += 100.0
self.assertEqual(eps.eps(), 0.0)
if __name__ == "__main__":
unittest.main()

View File

@ -2,7 +2,6 @@
import ast
import copy
import datetime
import logging
import math
import multiprocessing.queues
@ -10,7 +9,9 @@ import queue
import re
import shlex
import struct
import time
import urllib.parse
from collections import deque
from collections.abc import Mapping
from multiprocessing.managers import ValueProxy
from pathlib import Path
@ -32,23 +33,20 @@ class EventsPerSecond:
self._start = None
self._max_events = max_events
self._last_n_seconds = last_n_seconds
self._timestamps = []
self._timestamps: deque[float] = deque(maxlen=max_events)
def start(self) -> None:
self._start = datetime.datetime.now().timestamp()
self._start = time.monotonic()
def update(self) -> None:
now = datetime.datetime.now().timestamp()
now = time.monotonic()
if self._start is None:
self._start = now
self._timestamps.append(now)
# truncate the list when it goes 100 over the max_size
if len(self._timestamps) > self._max_events + 100:
self._timestamps = self._timestamps[(1 - self._max_events) :]
self.expire_timestamps(now)
def eps(self) -> float:
now = datetime.datetime.now().timestamp()
now = time.monotonic()
if self._start is None:
self._start = now
# compute the (approximate) events in the last n seconds
@ -63,7 +61,7 @@ class EventsPerSecond:
def expire_timestamps(self, now: float) -> None:
threshold = now - self._last_n_seconds
while self._timestamps and self._timestamps[0] < threshold:
del self._timestamps[0]
self._timestamps.popleft()
class InferenceSpeed:

18
web/package-lock.json generated
View File

@ -49,7 +49,7 @@
"framer-motion": "^12.38.0",
"hls.js": "^1.6.15",
"i18next": "^24.2.0",
"i18next-http-backend": "^3.0.1",
"i18next-http-backend": "^3.0.5",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1",
"js-yaml": "^4.1.1",
@ -7013,12 +7013,12 @@
}
},
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
"integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.12"
"node-fetch": "^2.7.0"
}
},
"node_modules/cross-spawn": {
@ -8876,12 +8876,12 @@
}
},
"node_modules/i18next-http-backend": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.1.tgz",
"integrity": "sha512-XT2lYSkbAtDE55c6m7CtKxxrsfuRQO3rUfHzj8ZyRtY9CkIX3aRGwXGTkUhpGWce+J8n7sfu3J0f2wTzo7Lw0A==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.5.tgz",
"integrity": "sha512-QaWHnsxieEDcqKe+vo/RFqpiIFRi/KBqlOSPcUlvinBaISCeiTRCbtrazHAjtHtsLC66oDsROAH8frWkQzfMMQ==",
"license": "MIT",
"dependencies": {
"cross-fetch": "4.0.0"
"cross-fetch": "4.1.0"
}
},
"node_modules/i18next-resources-for-ts": {

View File

@ -63,7 +63,7 @@
"framer-motion": "^12.38.0",
"hls.js": "^1.6.15",
"i18next": "^24.2.0",
"i18next-http-backend": "^3.0.1",
"i18next-http-backend": "^3.0.5",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1",
"js-yaml": "^4.1.1",