Merge pull request #1255 from JCBird1012:fix/stats-503-intermittent
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled

perf: reduce intermittent 503s on stats page and API requests
This commit is contained in:
SergeantPanda 2026-05-23 21:47:25 -05:00 committed by GitHub
commit c4b2fae85a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 58 additions and 57 deletions

View file

@ -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.

View file

@ -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,29 +461,31 @@ 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, 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',
}
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)
if user_id:
client_info['user_id'] = user_id
user_id_bytes = proxy_server.redis_client.hget(client_key, 'user_id')
if user_id_bytes:
client_info['user_id'] = user_id_bytes
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:

View file

@ -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:

View file

@ -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}")

View file

@ -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,12 +239,6 @@ const StatsPage = () => {
}
}, [refreshInterval, fetchChannelStats, fetchVODStats]);
// Fetch initial stats on component mount (for immediate data when navigating to page)
useEffect(() => {
fetchChannelStats();
fetchVODStats();
}, [fetchChannelStats, fetchVODStats]);
useEffect(() => {
console.log('Processing channel stats:', channelStats);
if (

View file

@ -220,8 +220,8 @@ describe('StatsPage', () => {
render(<StatsPage />);
await waitFor(() => {
expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
expect(getVODStats).toHaveBeenCalledTimes(2);
expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
expect(getVODStats).toHaveBeenCalledTimes(1);
});
});
@ -283,33 +283,37 @@ describe('StatsPage', () => {
render(<StatsPage />);
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();
});
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(<StatsPage />);
// Should still fetch once on mount even with interval = 0
expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
expect(getVODStats).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(10000);
});
// Should not have polled count stays at 1
expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
expect(getVODStats).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
@ -319,7 +323,7 @@ describe('StatsPage', () => {
const { unmount } = render(<StatsPage />);
expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
unmount();
@ -328,7 +332,7 @@ describe('StatsPage', () => {
});
// Should not fetch again after unmount
expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
@ -338,14 +342,14 @@ describe('StatsPage', () => {
it('refreshes stats when Refresh Now button is clicked', async () => {
render(<StatsPage />);
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 +410,14 @@ describe('StatsPage', () => {
render(<StatsPage />);
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);
});
});
});