From e2074c192116a6274249c047e84a7216c827d862 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Wed, 20 May 2026 21:52:28 -0400 Subject: [PATCH 1/5] fix: reduce intermittent 503s on stats page and API requests --- apps/proxy/live_proxy/channel_status.py | 28 +++++++++++++------------ docker/nginx.conf | 2 ++ frontend/src/pages/Stats.jsx | 6 +----- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/proxy/live_proxy/channel_status.py b/apps/proxy/live_proxy/channel_status.py index 3f75693e..2bf93ae8 100644 --- a/apps/proxy/live_proxy/channel_status.py +++ b/apps/proxy/live_proxy/channel_status.py @@ -419,7 +419,8 @@ class ChannelStatus: info['stream_name'] = stream_name # Add data throughput information to basic info - total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.TOTAL_BYTES) + # TOTAL_BYTES is already present in the hgetall result — avoid a redundant round-trip + total_bytes_bytes = metadata.get(ChannelMetadataField.TOTAL_BYTES) if total_bytes_bytes: total_bytes = int(total_bytes_bytes) info['total_bytes'] = total_bytes @@ -460,24 +461,25 @@ class ChannelStatus: client_key = RedisKeys.client_metadata(channel_id, client_id) + # Fetch only the fields we need in one round-trip (hmget returns a list + # in the same order as the requested keys; values are None if absent) + ua, ip, connected_at, user_id = proxy_server.redis_client.hmget( + client_key, 'user_agent', 'ip_address', 'connected_at', 'user_id' + ) + client_info = { 'client_id': client_id, + 'user_agent': ua, } - user_agent_bytes = proxy_server.redis_client.hget(client_key, 'user_agent') - client_info['user_agent'] = user_agent_bytes + if ip: + client_info['ip_address'] = ip - ip_address_bytes = proxy_server.redis_client.hget(client_key, 'ip_address') - if ip_address_bytes: - client_info['ip_address'] = ip_address_bytes + if connected_at: + client_info['connected_at'] = float(connected_at) - connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at') - if connected_at_bytes: - client_info['connected_at'] = float(connected_at_bytes) - - user_id_bytes = proxy_server.redis_client.hget(client_key, 'user_id') - if user_id_bytes: - client_info['user_id'] = user_id_bytes + if user_id: + client_info['user_id'] = user_id output_format = proxy_server.redis_client.hget(client_key, 'output_format') client_info['output_format'] = output_format or 'mpegts' diff --git a/docker/nginx.conf b/docker/nginx.conf index c5cb429e..bacfb655 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -21,6 +21,8 @@ server { location / { include uwsgi_params; uwsgi_pass unix:/app/uwsgi.sock; + uwsgi_read_timeout 300s; + uwsgi_send_timeout 300s; } location /assets/ { diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index ec5f7042..ea4ef5f2 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -239,11 +239,7 @@ const StatsPage = () => { } }, [refreshInterval, fetchChannelStats, fetchVODStats]); - // Fetch initial stats on component mount (for immediate data when navigating to page) - useEffect(() => { - fetchChannelStats(); - fetchVODStats(); - }, [fetchChannelStats, fetchVODStats]); + // Initial fetch is handled by the polling useEffect above (it fetches immediately on mount) useEffect(() => { console.log('Processing channel stats:', channelStats); From 6787e4912fc8ff9f5a93db62e4cfb930e783ac01 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Wed, 20 May 2026 22:12:09 -0400 Subject: [PATCH 2/5] refactor: replace multiple hget calls with hmget where fields are known - channel_status.py: fold output_format and output_profile_id into the existing hmget, reducing per-client Redis calls from 3 to 1 - utils.py: collapse 2x and 3x hget per key in scan_iter loops (get_user_active_connections) to single hmget calls - views.py: replace 3x hget on channel metadata in the worker-join path of stream_ts with a single hmget --- apps/proxy/live_proxy/channel_status.py | 13 +++++++------ apps/proxy/live_proxy/views.py | 13 +++++-------- apps/proxy/utils.py | 9 ++++----- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/apps/proxy/live_proxy/channel_status.py b/apps/proxy/live_proxy/channel_status.py index 2bf93ae8..9bd1d284 100644 --- a/apps/proxy/live_proxy/channel_status.py +++ b/apps/proxy/live_proxy/channel_status.py @@ -463,13 +463,18 @@ class ChannelStatus: # Fetch only the fields we need in one round-trip (hmget returns a list # in the same order as the requested keys; values are None if absent) - ua, ip, connected_at, user_id = proxy_server.redis_client.hmget( - client_key, 'user_agent', 'ip_address', 'connected_at', 'user_id' + ua, ip, connected_at, user_id, output_format, raw_profile_id = ( + proxy_server.redis_client.hmget( + client_key, + 'user_agent', 'ip_address', 'connected_at', 'user_id', + 'output_format', 'output_profile_id', + ) ) client_info = { 'client_id': client_id, 'user_agent': ua, + 'output_format': output_format or 'mpegts', } if ip: @@ -481,10 +486,6 @@ class ChannelStatus: if user_id: client_info['user_id'] = user_id - output_format = proxy_server.redis_client.hget(client_key, 'output_format') - client_info['output_format'] = output_format or 'mpegts' - - raw_profile_id = proxy_server.redis_client.hget(client_key, 'output_profile_id') if raw_profile_id and raw_profile_id not in ('None', '0', ''): client_info['output_profile_id'] = int(raw_profile_id) else: diff --git a/apps/proxy/live_proxy/views.py b/apps/proxy/live_proxy/views.py index 1369a364..bf39f6a9 100644 --- a/apps/proxy/live_proxy/views.py +++ b/apps/proxy/live_proxy/views.py @@ -468,14 +468,11 @@ def stream_ts(request, channel_id, user=None, force_output_format=None): if proxy_server.redis_client: metadata_key = RedisKeys.channel_metadata(channel_id) - url_bytes = proxy_server.redis_client.hget( - metadata_key, ChannelMetadataField.URL - ) - ua_bytes = proxy_server.redis_client.hget( - metadata_key, ChannelMetadataField.USER_AGENT - ) - profile_bytes = proxy_server.redis_client.hget( - metadata_key, ChannelMetadataField.STREAM_PROFILE + url_bytes, ua_bytes, profile_bytes = proxy_server.redis_client.hmget( + metadata_key, + ChannelMetadataField.URL, + ChannelMetadataField.USER_AGENT, + ChannelMetadataField.STREAM_PROFILE, ) if url_bytes: diff --git a/apps/proxy/utils.py b/apps/proxy/utils.py index db2339ed..69ee15e4 100644 --- a/apps/proxy/utils.py +++ b/apps/proxy/utils.py @@ -98,8 +98,7 @@ def get_user_active_connections(user_id): channel_id = parts[2] client_id = parts[4] - client_user_id = redis_client.hget(key, 'user_id') - connected_at = redis_client.hget(key, 'connected_at') + client_user_id, connected_at = redis_client.hmget(key, 'user_id', 'connected_at') logger.debug(f"[stream limits] user_id = {user_id}") logger.debug(f"[stream limits] channel_id = {channel_id}") @@ -124,9 +123,9 @@ def get_user_active_connections(user_id): if len(parts) >= 2: client_id = parts[1] - client_user_id = redis_client.hget(key, 'user_id') - connected_at = redis_client.hget(key, 'created_at') - content_uuid = redis_client.hget(key, 'content_uuid') + client_user_id, connected_at, content_uuid = redis_client.hmget( + key, 'user_id', 'created_at', 'content_uuid' + ) logger.debug(f"[stream limits] user_id = {user_id}") logger.debug(f"[stream limits] client_id = {client_id}") From a2c69be861d38711e40f0dad1976500b33423f12 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Wed, 20 May 2026 22:22:48 -0400 Subject: [PATCH 3/5] fix: update Stats tests to expect single mount fetch after removing duplicate useEffect Tests were asserting fetchActiveChannelStats/getVODStats were called twice on mount, encoding React strict mode's double-invoke behavior rather than testing actual application logic. Update all affected call count assertions to reflect the correct single-fetch behavior. --- frontend/src/pages/__tests__/Stats.test.jsx | 30 ++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/frontend/src/pages/__tests__/Stats.test.jsx b/frontend/src/pages/__tests__/Stats.test.jsx index d809a47a..21cfebf7 100644 --- a/frontend/src/pages/__tests__/Stats.test.jsx +++ b/frontend/src/pages/__tests__/Stats.test.jsx @@ -220,8 +220,8 @@ describe('StatsPage', () => { render(); await waitFor(() => { - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); - expect(getVODStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); }); }); @@ -283,16 +283,16 @@ describe('StatsPage', () => { render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); - expect(getVODStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); // Advance timers by 5 seconds await act(async () => { vi.advanceTimersByTime(5000); }); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3); - expect(getVODStats).toHaveBeenCalledTimes(3); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(getVODStats).toHaveBeenCalledTimes(2); vi.useRealTimers(); }); @@ -303,13 +303,13 @@ describe('StatsPage', () => { useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]); render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(0); await act(async () => { vi.advanceTimersByTime(10000); }); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(0); vi.useRealTimers(); }); @@ -319,7 +319,7 @@ describe('StatsPage', () => { const { unmount } = render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); unmount(); @@ -328,7 +328,7 @@ describe('StatsPage', () => { }); // Should not fetch again after unmount - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); @@ -338,14 +338,14 @@ describe('StatsPage', () => { it('refreshes stats when Refresh Now button is clicked', async () => { render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); const refreshButton = screen.getByText('Refresh Now'); fireEvent.click(refreshButton); await waitFor(() => { - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3); - expect(getVODStats).toHaveBeenCalledTimes(3); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(getVODStats).toHaveBeenCalledTimes(2); }); }); }); @@ -406,14 +406,14 @@ describe('StatsPage', () => { render(); await waitFor(() => { - expect(getVODStats).toHaveBeenCalledTimes(2); + expect(getVODStats).toHaveBeenCalledTimes(1); }); const stopButton = await screen.findByTestId('stop-vod-client-client-1'); fireEvent.click(stopButton); await waitFor(() => { - expect(getVODStats).toHaveBeenCalledTimes(3); + expect(getVODStats).toHaveBeenCalledTimes(2); }); }); }); From 0d1d1f27221d883fbc675edc0f78ccbbf26115e0 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Sat, 23 May 2026 14:20:13 -0400 Subject: [PATCH 4/5] fix: revert nginx timeouts; fire stats fetch on mount regardless of interval --- docker/nginx.conf | 2 -- frontend/src/pages/Stats.jsx | 16 +++++++--------- frontend/src/pages/__tests__/Stats.test.jsx | 10 +++++++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docker/nginx.conf b/docker/nginx.conf index bacfb655..c5cb429e 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -21,8 +21,6 @@ server { location / { include uwsgi_params; uwsgi_pass unix:/app/uwsgi.sock; - uwsgi_read_timeout 300s; - uwsgi_send_timeout 300s; } location /assets/ { diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index ea4ef5f2..86c7f1c3 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -212,19 +212,19 @@ const StatsPage = () => { } }, [setVodStats]); + // Always fetch once on mount, regardless of polling interval setting + useEffect(() => { + fetchChannelStats(); + fetchVODStats(); + }, [fetchChannelStats, fetchVODStats]); + // Set up polling for stats when on stats page useEffect(() => { - const location = window.location; - const isOnStatsPage = location.pathname === '/stats'; + const isOnStatsPage = window.location.pathname === '/stats'; if (isOnStatsPage && refreshInterval > 0) { setIsPollingActive(true); - // Initial fetch - fetchChannelStats(); - fetchVODStats(); - - // Set up interval const interval = setInterval(() => { fetchChannelStats(); fetchVODStats(); @@ -239,8 +239,6 @@ const StatsPage = () => { } }, [refreshInterval, fetchChannelStats, fetchVODStats]); - // Initial fetch is handled by the polling useEffect above (it fetches immediately on mount) - useEffect(() => { console.log('Processing channel stats:', channelStats); if ( diff --git a/frontend/src/pages/__tests__/Stats.test.jsx b/frontend/src/pages/__tests__/Stats.test.jsx index 21cfebf7..92cf8560 100644 --- a/frontend/src/pages/__tests__/Stats.test.jsx +++ b/frontend/src/pages/__tests__/Stats.test.jsx @@ -297,19 +297,23 @@ describe('StatsPage', () => { vi.useRealTimers(); }); - it('does not poll when interval is 0', async () => { + it('does not poll when interval is 0 but still fetches once on mount', async () => { vi.useFakeTimers(); useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]); render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(0); + // Should still fetch once on mount even with interval = 0 + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); await act(async () => { vi.advanceTimersByTime(10000); }); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(0); + // Should not have polled — count stays at 1 + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); From fb260356aad4ff8b44035fe88cc769742df361a7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 23 May 2026 21:45:49 -0500 Subject: [PATCH 5/5] changelog: Update changelog for stats PR. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3464dd31..7cef64db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Performance + +- **Reduced Redis round-trips on the Stats page channel status endpoint.** `get_basic_channel_info` was making up to 6 individual `HGET` calls per connected client plus a redundant `HGET` for `TOTAL_BYTES` (already present in the preceding `HGETALL` result). Client metadata is now fetched with a single `HMGET` per client, and `TOTAL_BYTES` is read from the already-fetched hash. Under load with many active streams this significantly reduces the time each uWSGI worker holds the GIL servicing the stats endpoint, reducing the chance of concurrent requests from other pages timing out with a 503. The same `HGET`-to-`HMGET` consolidation was applied to `stream_ts` and `get_user_active_connections`. The Stats page frontend was also fixed to fire the initial fetch only once on mount (previously two `useEffect` hooks both triggered an immediate fetch on load). — Thanks [@JCBird1012](https://github.com/JCBird1012) + ### Fixed - **Restoring a backup from an older version left the database with missing schema.** The restore task ran `pg_restore` which replaced the entire database (including the `django_migrations` table) but did not run migrations afterward. If the backup predated a schema migration, the restored database was missing tables and columns added by those migrations, causing 500 errors on every API call. `migrate --noinput` now runs automatically after every restore. The success notification also now recommends a restart to clear stale service state.