mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-08-05 00:02:54 +00:00
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
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:
commit
c4b2fae85a
6 changed files with 58 additions and 57 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue