audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

reduce baseline memory ~50-70MB by deferring heavy imports (#1241)

* reduce baseline memory ~50-70MB by deferring heavy imports

- move psycopg[binary] from production to dev deps (only needed for
alembic migrations, app uses asyncpg via greenlet bridge)
- defer r2.py import in storage/__init__.py (boto3/botocore loaded
on first storage access, not at startup)
- defer aioboto3 + heavy deps in export_tasks.py into process_export()
(background task that runs rarely, was pulling in full boto3 stack
at startup via api/exports.py import chain)
- defer export_tasks import in tasks/__init__.py (matching existing
jetstream deferral pattern)

production machine: 1GB RAM, 440MB RSS at idle. these changes remove
~50-70MB of imports that were loaded at startup but only needed during
uploads/exports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix beartype NameError: use string annotations for deferred type

beartype_this_package() evaluates annotations at import time, so
`from __future__ import annotations` doesn't help. use string
literal annotations instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix beartype forward ref: use Any annotation for deferred storage

beartype_this_package() resolves forward references on module-level
variables at import time, even with string annotations. use Any to
avoid the forward ref while keeping ty happy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix test patch targets for deferred imports in export_tasks

Since aioboto3, aiofiles, db_session, and storage are now imported
inside process_export() rather than at module level, patches must
target the source modules directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4.6
and committed by
GitHub
248da658 262eab6e

+30 -26
+1 -1
backend/pyproject.toml
··· 18 18 "python-multipart>=0.0.20", 19 19 "python-jose[cryptography]>=3.3.0", 20 20 "passlib[bcrypt]>=1.7.4", 21 - "psycopg[binary]>=3.2.12", 22 21 "greenlet>=3.2.4", 23 22 "logfire[fastapi,sqlalchemy]>=4.26.0", 24 23 "cachetools>=6.2.1", ··· 43 42 [dependency-groups] 44 43 dev = [ 45 44 "dirty-equals>=0.9.0", 45 + "psycopg[binary]>=3.2.12", 46 46 "ipython>=8.12.3", 47 47 "pdbpp>=0.10.3", 48 48 "plyrfm @ git+https://github.com/zzstoatzz/plyr-python-client#subdirectory=packages/plyrfm",
+13 -9
backend/src/backend/_internal/export_tasks.py
··· 10 10 from datetime import datetime 11 11 from pathlib import Path 12 12 13 - import aioboto3 14 - import aiofiles 15 13 import logfire 16 - from sqlalchemy import select 17 14 18 15 from backend._internal.background import get_docket 19 16 from backend._internal.jobs import job_service 20 - from backend.config import settings 21 - from backend.models import Track 22 17 from backend.models.job import JobStatus 23 - from backend.storage import storage 24 - from backend.storage.r2 import UploadProgressTracker 25 - from backend.utilities.database import db_session 26 - from backend.utilities.progress import R2ProgressTracker 27 18 28 19 logger = logging.getLogger(__name__) 29 20 ··· 38 29 export_id: job ID for tracking progress 39 30 artist_did: DID of the artist whose tracks to export 40 31 """ 32 + # heavy imports deferred: this background task runs rarely and 33 + # aioboto3 pulls in all of boto3/botocore (~30-40MB RSS) 34 + import aioboto3 35 + import aiofiles 36 + from sqlalchemy import select 37 + 38 + from backend.config import settings 39 + from backend.models import Track 40 + from backend.storage import storage 41 + from backend.storage.r2 import UploadProgressTracker 42 + from backend.utilities.database import db_session 43 + from backend.utilities.progress import R2ProgressTracker 44 + 41 45 try: 42 46 await job_service.update_progress( 43 47 export_id, JobStatus.PROCESSING, "fetching tracks..."
+3 -3
backend/src/backend/_internal/tasks/__init__.py
··· 6 6 requires DOCKET_URL to be set (Redis is always available). 7 7 """ 8 8 9 - from backend._internal.export_tasks import process_export 10 9 from backend._internal.pds_backfill_tasks import backfill_tracks_to_pds 11 10 from backend._internal.tasks.copyright import ( 12 11 scan_copyright, ··· 68 67 69 68 70 69 def _build_background_tasks() -> list: 71 - """build the task list, deferring jetstream import to break circular dep. 70 + """build the task list, deferring heavy imports to reduce startup memory. 72 71 73 - cycle: jetstream.py → tasks.ingest → tasks/__init__.py → jetstream.py 72 + deferred: jetstream (circular dep), export_tasks (pulls in boto3/botocore) 74 73 """ 74 + from backend._internal.export_tasks import process_export 75 75 from backend._internal.jetstream import consume_jetstream 76 76 77 77 return [
+6 -3
backend/src/backend/storage/__init__.py
··· 2 2 3 3 from typing import TYPE_CHECKING 4 4 5 + from typing import Any 6 + 5 7 from backend.storage.protocol import StorageProtocol 6 - from backend.storage.r2 import R2Storage 7 8 8 - _storage: R2Storage | None = None 9 + _storage: Any = None 9 10 10 11 11 - def _get_storage() -> R2Storage: 12 + def _get_storage(): 12 13 """lazily initialize storage on first access.""" 13 14 global _storage 14 15 if _storage is None: 16 + from backend.storage.r2 import R2Storage 17 + 15 18 _storage = R2Storage() 16 19 return _storage 17 20
+5 -8
backend/tests/test_background_tasks.py
··· 171 171 mock_storage.audio_bucket_name = "test-audio-bucket" 172 172 173 173 with ( 174 - patch( 175 - "backend._internal.export_tasks.aioboto3.Session", 176 - return_value=mock_session, 177 - ), 178 - patch("backend._internal.export_tasks.aiofiles.open", return_value=mock_file), 174 + patch("aioboto3.Session", return_value=mock_session), 175 + patch("aiofiles.open", return_value=mock_file), 179 176 patch("backend._internal.export_tasks.zipfile.ZipFile"), 180 177 patch("backend._internal.export_tasks.os.unlink"), 181 - patch("backend._internal.export_tasks.db_session") as mock_db_session, 182 - patch("backend._internal.export_tasks.job_service", mock_job_service), 183 - patch("backend._internal.export_tasks.storage", mock_storage), 178 + patch("backend.utilities.database.db_session") as mock_db_session, 179 + patch.object(export_tasks, "job_service", mock_job_service), 180 + patch("backend.storage.storage", mock_storage), 184 181 ): 185 182 mock_db_session.return_value.__aenter__.return_value = mock_db 186 183
+2 -2
backend/uv.lock
··· 343 343 { name = "orjson" }, 344 344 { name = "passlib", extra = ["bcrypt"] }, 345 345 { name = "pillow" }, 346 - { name = "psycopg", extra = ["binary"] }, 347 346 { name = "pydantic" }, 348 347 { name = "pydantic-settings" }, 349 348 { name = "pydocket" }, ··· 366 365 { name = "pdbpp" }, 367 366 { name = "plyrfm" }, 368 367 { name = "prek" }, 368 + { name = "psycopg", extra = ["binary"] }, 369 369 { name = "pytest" }, 370 370 { name = "pytest-asyncio" }, 371 371 { name = "pytest-cov" }, ··· 393 393 { name = "orjson", specifier = ">=3.11.4" }, 394 394 { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, 395 395 { name = "pillow", specifier = ">=11.0.0" }, 396 - { name = "psycopg", extras = ["binary"], specifier = ">=3.2.12" }, 397 396 { name = "pydantic", specifier = ">=2.11.0" }, 398 397 { name = "pydantic-settings", specifier = ">=2.7.0" }, 399 398 { name = "pydocket", specifier = ">=0.15.2" }, ··· 416 415 { name = "pdbpp", specifier = ">=0.10.3" }, 417 416 { name = "plyrfm", git = "https://github.com/zzstoatzz/plyr-python-client?subdirectory=packages%2Fplyrfm" }, 418 417 { name = "prek", specifier = ">=0.2.13" }, 418 + { name = "psycopg", extras = ["binary"], specifier = ">=3.2.12" }, 419 419 { name = "pytest", specifier = ">=8.3.3" }, 420 420 { name = "pytest-asyncio", specifier = ">=1.0.0" }, 421 421 { name = "pytest-cov", specifier = ">=6.1.1" },