Skip to content

Commit 98362aa

Browse files
Suncussclaude
andcommitted
perf: cached model layers and tzfpy shrink image and update downloads; delete every media variant a detection owns
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M46kR13yG1Gi4zRDUZv6SY
1 parent 089d788 commit 98362aa

10 files changed

Lines changed: 311 additions & 475 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
## [Unreleased]
44

5+
- Fixed detection deletion removing only one copy of a recording's media when duplicates existed under different naming eras (source-ID transition, legacy colon pattern); the surviving copy was orphaned on disk forever. Deletion now removes every variant it finds
56
- Fixed detection deletion responses reporting the generated filename instead of the legacy media file actually removed
67
- Fixed automatic storage cleanup skipping audio and spectrogram files created during the source-ID filename transition, allowing upgraded stations to reclaim those legacy recordings
78
- Improved native updates on Raspberry Pis by stabilizing Docker base layers between releases and removing the backend compiler toolchain from the runtime image, reducing both routine downloads and slow SD-card extraction
9+
- Improved update download size again: bundled model files (V2.4 model, species table, eBird codes) now live in their own cached image layers instead of re-downloading with every release, shrinking a routine update's backend download from ~55MB to a few megabytes
10+
- Changed the offline timezone lookup (used when saving a station location) to a much smaller library, trimming ~55MB from the backend image and removing the last need for a compiler during image builds
811
- Fixed failed update checks exposing the exact installed commit to anonymous visitors through detailed GitHub error URLs; diagnostics remain available to signed-in owners and in logs
912
- Fixed authentication and settings temporary files being readable by other local users while sensitive content was still being written; private files now start owner-only
1013
- Fixed a password-change race that could leave previously issued audio links valid until the API service restarted

backend/Dockerfile

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,10 @@
33
# both the AMD64 and ARM64 images built by CI.
44
FROM python:3.11-slim@sha256:94c50be2dc994b873b55bc123e95e6dbade08095b3dfd790f51c34de3f08cbb7 AS python-deps
55

6-
# Build native wheels in a disposable stage. timezonefinder currently builds
7-
# from source on ARM64, so a compiler is still required during image creation,
8-
# but none of this toolchain needs to ship to stations.
9-
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
10-
--mount=type=cache,target=/var/lib/apt,sharing=locked \
11-
apt-get update && apt-get install -y --no-install-recommends \
12-
build-essential
13-
6+
# Assemble the venv in a disposable stage. Every dependency ships a prebuilt
7+
# ARM64 wheel, so no compiler toolchain is installed; if a future dependency
8+
# starts building from source, pip fails loudly here instead of silently
9+
# requiring one.
1410
RUN python -m venv /opt/venv
1511
ENV PATH="/opt/venv/bin:$PATH"
1612

@@ -24,14 +20,23 @@ RUN --mount=type=cache,target=/root/.cache/pip \
2420
pip install --extra-index-url https://www.piwheels.org/simple \
2521
-r requirements.txt
2622

23+
# Prune bundled models out of the per-release source layer. They ship in the
24+
# dedicated COPY layers below instead, so a routine source change pushes a few
25+
# megabytes, not the ~55MB of model blobs. The prune must be explicit: BuildKit
26+
# only dedupes files identical to lower layers on some builders (CI yes,
27+
# local dockerd no), so leaving them in COPY . . re-ships them from local builds.
28+
FROM python:3.11-slim@sha256:94c50be2dc994b873b55bc123e95e6dbade08095b3dfd790f51c34de3f08cbb7 AS source
29+
COPY . /src
30+
RUN rm -rf /src/model_service/models
31+
2732
# Use the same pinned multi-platform base for the shipped runtime image.
2833
FROM python:3.11-slim@sha256:94c50be2dc994b873b55bc123e95e6dbade08095b3dfd790f51c34de3f08cbb7
2934

3035
# Build arguments for user/group IDs (defaults for fallback)
3136
ARG UID=1000
3237
ARG GID=1000
3338

34-
# Install runtime libraries only; the compiler toolchain stays in python-deps.
39+
# Install runtime libraries only.
3540
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
3641
--mount=type=cache,target=/var/lib/apt,sharing=locked \
3742
apt-get update && apt-get install -y --no-install-recommends \
@@ -43,8 +48,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
4348

4449
WORKDIR /app
4550

46-
# Copy only the finished environment; build-essential and intermediate wheel
47-
# caches remain in the discarded python-deps stage.
51+
# Copy only the finished environment; intermediate wheel caches remain in the
52+
# discarded python-deps stage.
4853
COPY --from=python-deps /opt/venv /opt/venv
4954
ENV PATH="/opt/venv/bin:$PATH"
5055

@@ -70,7 +75,15 @@ RUN python -m model_service.birdnet_v3_assets
7075
# Compose bind-mounts the checkout over /app, so runtime loaders repeat the
7176
# authoritative checks against the bytes each service actually reads.
7277

73-
COPY --chown=${UID}:${GID} . .
78+
# Remaining bundled model data in its own rarely-invalidated layers, after the
79+
# gates so a species-table refresh does not re-run the memory-intensive checks.
80+
COPY --chown=${UID}:${GID} model_service/models/v2.4 ./model_service/models/v2.4
81+
COPY --chown=${UID}:${GID} model_service/models/species_table.csv \
82+
model_service/models/ebird_codes.json \
83+
./model_service/models/
84+
85+
# Source code last: the only layer that changes on a routine release.
86+
COPY --from=source --chown=${UID}:${GID} /src ./
7487

7588
# Copy entrypoint script
7689
COPY docker-entrypoint.sh /usr/local/bin/

backend/Dockerfile.test

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
FROM python:3.11-slim
33

44
RUN apt-get update && apt-get install -y --no-install-recommends \
5-
build-essential \
65
ffmpeg \
76
&& rm -rf /var/lib/apt/lists/*
87

backend/core/routes/settings.py

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
core.settings_store. Registered on the shared ``api`` blueprint at import.
77
"""
88
import re
9-
import threading
109

1110
from flask import jsonify, request
1211

@@ -42,32 +41,21 @@
4241
logger = get_logger(__name__)
4342

4443

45-
# Singleton TimezoneFinder (loads ~40MB shape data on first use). The import
46-
# itself is deferred into _get_timezone_finder(): it drags numpy/cffi/h3 into
47-
# the worker, and the only caller is the settings handler resolving a newly
48-
# saved location — a station that never edits its location never pays for it.
49-
_timezone_finder = None
50-
_tz_finder_lock = threading.Lock() # hub-only: timezone lookup runs in settings-route greenlets, never the DB lane
51-
52-
53-
def _get_timezone_finder():
54-
"""Lazy-import and lazy-load TimezoneFinder (loads ~40MB shape data)."""
55-
global _timezone_finder
56-
with _tz_finder_lock:
57-
if _timezone_finder is None:
58-
from timezonefinder import TimezoneFinder
59-
_timezone_finder = TimezoneFinder()
60-
return _timezone_finder
61-
62-
6344
def get_timezone_for_location(lat: float, lon: float) -> str | None:
64-
"""Offline timezone lookup. Returns IANA timezone or None on failure."""
45+
"""Offline timezone lookup. Returns IANA timezone or None on failure.
46+
47+
tzfpy's simplified polygons leave rare hairline gaps at zone borders
48+
(and at exactly ±180° longitude) where lookup returns nothing; retrying
49+
a few km to each side recovers a point sitting in such a gap. The import
50+
stays deferred so only the location-save path ever pays for it.
51+
"""
6552
try:
66-
tf = _get_timezone_finder()
67-
timezone = tf.timezone_at(lat=lat, lng=lon)
68-
if timezone:
69-
logger.info(f"Resolved timezone: {timezone}")
70-
return timezone
53+
from tzfpy import get_tz
54+
for dlat, dlon in ((0, 0), (0.1, 0), (-0.1, 0), (0, 0.1), (0, -0.1)):
55+
timezone = get_tz(lon + dlon, lat + dlat) # tzfpy takes lng first
56+
if timezone:
57+
logger.info(f"Resolved timezone: {timezone}")
58+
return timezone
7159
logger.warning(f"No timezone found for ({lat}, {lon})")
7260
return None
7361
except Exception as e:

backend/core/storage_manager.py

Lines changed: 29 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -75,29 +75,6 @@ def get_disk_usage(path=None):
7575
}
7676

7777

78-
def _resolve_path_with_legacy_fallback(filename, directory):
79-
"""Resolve file path, falling back to legacy colon-pattern if needed.
80-
81-
Args:
82-
filename: Filename (dash-pattern)
83-
directory: Directory containing the file
84-
85-
Returns:
86-
Full path to the file (dash or legacy pattern, whichever exists)
87-
"""
88-
path = os.path.join(directory, filename)
89-
if os.path.exists(path):
90-
return path
91-
92-
legacy_filename = get_legacy_filename(filename)
93-
if legacy_filename:
94-
legacy_path = os.path.join(directory, legacy_filename)
95-
if os.path.exists(legacy_path):
96-
return legacy_path
97-
98-
return path # Return original path even if it doesn't exist
99-
100-
10178
def _detection_filename_candidates(detection):
10279
"""Build ordered dash-pattern filename candidates for a detection.
10380
@@ -137,38 +114,26 @@ def _detection_filename_candidates(detection):
137114
)
138115

139116

140-
def _resolve_detection_path(filename_candidates, key, directory):
141-
"""Return the first existing path for one detection media type."""
142-
first_path = None
143-
for filenames in filename_candidates:
144-
path = _resolve_path_with_legacy_fallback(filenames[key], directory)
145-
if first_path is None:
146-
first_path = path
147-
if os.path.exists(path):
148-
return path
149-
return first_path
150-
151-
152-
def get_detection_files(detection):
153-
"""Get full file paths for a detection record.
117+
def _existing_detection_paths(detection):
118+
"""Every on-disk path holding the detection's media, audio paths first.
154119
155-
Supports lazy migration: if new dash-pattern files don't exist,
156-
falls back to checking for old colon-pattern files.
157-
158-
Args:
159-
detection: dict with common_name, confidence, timestamp
160-
161-
Returns:
162-
dict with audio_path and spectrogram_path
120+
Checks each filename candidate in both dash and legacy colon patterns
121+
and keeps all hits, not just the first: transition-era rows can own
122+
duplicate copies under different naming eras, and a copy that survived
123+
deletion would be orphaned forever (cleanup only ever revisits DB rows).
163124
"""
164-
filename_candidates = _detection_filename_candidates(detection)
165-
166-
return {
167-
'audio_path': _resolve_detection_path(
168-
filename_candidates, 'audio_filename', EXTRACTED_AUDIO_DIR),
169-
'spectrogram_path': _resolve_detection_path(
170-
filename_candidates, 'spectrogram_filename', SPECTROGRAM_DIR),
171-
}
125+
candidates = _detection_filename_candidates(detection)
126+
paths = []
127+
for key, directory in (('audio_filename', EXTRACTED_AUDIO_DIR),
128+
('spectrogram_filename', SPECTROGRAM_DIR)):
129+
for filenames in candidates:
130+
for name in (filenames[key], get_legacy_filename(filenames[key])):
131+
if name is None:
132+
continue
133+
path = os.path.join(directory, name)
134+
if os.path.exists(path):
135+
paths.append(path)
136+
return paths
172137

173138

174139
def _disk_filename_sets():
@@ -205,51 +170,26 @@ def _has_files_on_disk(detection, audio_names, spectrogram_names):
205170

206171

207172
def delete_detection_files(detection):
208-
"""Delete audio and spectrogram files for a detection.
173+
"""Delete every audio and spectrogram file a detection owns.
209174
210175
Args:
211176
detection: dict with common_name, confidence, timestamp
212177
213178
Returns:
214-
dict with deleted_audio, deleted_spectrogram, deleted_filenames,
215-
and bytes_freed
179+
dict with deleted_filenames (the names actually removed, audio
180+
first) and bytes_freed
216181
"""
217-
paths = get_detection_files(detection)
218-
result = {
219-
'deleted_audio': False,
220-
'deleted_spectrogram': False,
221-
'deleted_filenames': [],
222-
'bytes_freed': 0
223-
}
224-
225-
# Delete audio file
226-
audio_path = paths['audio_path']
227-
if audio_path and os.path.exists(audio_path):
228-
try:
229-
size = os.path.getsize(audio_path)
230-
os.remove(audio_path)
231-
result['deleted_audio'] = True
232-
result['deleted_filenames'].append(os.path.basename(audio_path))
233-
result['bytes_freed'] += size
234-
except OSError as e:
235-
logger.warning("Failed to delete audio file", extra={
236-
'path': audio_path,
237-
'error': str(e)
238-
})
182+
result = {'deleted_filenames': [], 'bytes_freed': 0}
239183

240-
# Delete spectrogram file
241-
spectrogram_path = paths['spectrogram_path']
242-
if spectrogram_path and os.path.exists(spectrogram_path):
184+
for path in _existing_detection_paths(detection):
243185
try:
244-
size = os.path.getsize(spectrogram_path)
245-
os.remove(spectrogram_path)
246-
result['deleted_spectrogram'] = True
247-
result['deleted_filenames'].append(
248-
os.path.basename(spectrogram_path))
186+
size = os.path.getsize(path)
187+
os.remove(path)
188+
result['deleted_filenames'].append(os.path.basename(path))
249189
result['bytes_freed'] += size
250190
except OSError as e:
251-
logger.warning("Failed to delete spectrogram file", extra={
252-
'path': spectrogram_path,
191+
logger.warning("Failed to delete detection file", extra={
192+
'path': path,
253193
'error': str(e)
254194
})
255195

@@ -422,7 +362,7 @@ def delete_pass(start_cursor, stop_cursor=None):
422362
continue
423363

424364
delete_result = delete_detection_files(detection)
425-
if delete_result['deleted_audio'] or delete_result['deleted_spectrogram']:
365+
if delete_result['deleted_filenames']:
426366
result['files_deleted'] += 1
427367
result['bytes_freed'] += delete_result['bytes_freed']
428368
return cursor

backend/requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ requests>=2.32.4
2828
apprise>=1.8.0
2929
paho-mqtt>=2.0.0
3030

31-
# Timezone utilities
32-
timezonefinder>=6.2.0
31+
# Timezone utilities (Rust wheel, no compiler needed; ~50MB smaller than timezonefinder)
32+
tzfpy>=1.3.2
3333

3434
# Visualization (spectrogram rendering)
3535
Pillow>=10.4.0

backend/tests/api/conftest.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,23 @@ def media_dirs():
207207
yield audio_dir, spectrogram_dir
208208

209209

210+
@pytest.fixture
211+
def storage_media_dirs():
212+
"""Patch storage_manager's audio/spectrogram directories to empty temp dirs.
213+
214+
Detection deletion resolves and removes media files through
215+
core.storage_manager; tests create files here to control what gets
216+
deleted and reported."""
217+
with tempfile.TemporaryDirectory() as tmp:
218+
audio_dir = os.path.join(tmp, 'extracted_songs')
219+
spectrogram_dir = os.path.join(tmp, 'spectrograms')
220+
os.makedirs(audio_dir)
221+
os.makedirs(spectrogram_dir)
222+
with patch('core.storage_manager.EXTRACTED_AUDIO_DIR', audio_dir), \
223+
patch('core.storage_manager.SPECTROGRAM_DIR', spectrogram_dir):
224+
yield audio_dir, spectrogram_dir
225+
226+
210227
@pytest.fixture
211228
def create_recording_files(media_dirs):
212229
"""Factory that creates on-disk audio+spectrogram files for a species'

0 commit comments

Comments
 (0)