Switched to owner staying as initializer.

This commit is contained in:
SergeantPanda 2025-03-09 19:37:15 -05:00
parent ddec51ec5b
commit 5563bd0d5e
3 changed files with 209 additions and 86 deletions

View file

@ -41,5 +41,8 @@ class TSConfig(BaseConfig):
# Resource management
CLEANUP_INTERVAL = 60 # Check for inactive channels every 60 seconds
CHANNEL_GRACE_PERIOD = 0 # Wait 0 seconds after last client disconnects before stopping
# Client tracking settings
CLIENT_RECORD_TTL = 5 # How long client records persist in Redis (seconds). Client will be considered MIA after this time.

View file

@ -197,6 +197,7 @@ class StreamManager:
health_thread.start()
current_response = None # Track the current response object
current_session = None # Track the current session
# Establish network connection
import socket
@ -210,6 +211,7 @@ class StreamManager:
if self.url.startswith("http"):
# HTTP connection
session = self._create_session()
current_session = session
try:
# Create an initial connection to get socket
@ -242,7 +244,13 @@ class StreamManager:
except Exception as e:
logging.debug(f"Error closing response: {e}")
current_response = None
session.close()
if current_session:
try:
current_session.close()
except Exception as e:
logging.debug(f"Error closing session: {e}")
current_session = None
else:
logging.error(f"Unsupported URL scheme: {self.url}")
@ -541,34 +549,95 @@ class StreamBuffer:
return []
class ClientManager:
"""Manages connected clients for a channel with activity tracking"""
"""Manages connected clients for a channel with cross-worker visibility"""
def __init__(self):
self.clients = set()
def __init__(self, channel_id, redis_client=None):
self.channel_id = channel_id
self.redis_client = redis_client
self.clients = set() # Local clients only
self.lock = threading.Lock()
self.last_active_time = time.time()
self.client_set_key = f"ts_proxy:channel:{channel_id}:clients"
self.client_ttl = getattr(Config, 'CLIENT_RECORD_TTL', 5)
def add_client(self, client_id):
"""Add a client to this channel"""
"""Add a client to this channel locally and in Redis"""
with self.lock:
self.clients.add(client_id)
self.last_active_time = time.time()
logging.info(f"New client connected: {client_id} (total: {len(self.clients)})")
# Track in Redis if available
if self.redis_client:
# Add to channel's client set
self.redis_client.sadd(self.client_set_key, client_id)
# Set TTL on the whole set
self.redis_client.expire(self.client_set_key, self.client_ttl)
# Also track client individually with TTL for cleanup
client_key = f"ts_proxy:client:{self.channel_id}:{client_id}"
self.redis_client.setex(client_key, self.client_ttl, "1")
# Get total clients across all workers
total_clients = self.get_total_client_count()
logging.info(f"New client connected: {client_id} (local: {len(self.clients)}, total: {total_clients})")
return len(self.clients)
def remove_client(self, client_id):
"""Remove a client from this channel and return remaining count"""
"""Remove a client from this channel and Redis"""
with self.lock:
if client_id in self.clients:
self.clients.remove(client_id)
self.last_active_time = time.time() # Update activity time on disconnect too
logging.info(f"Client disconnected: {client_id} (remaining: {len(self.clients)})")
self.last_active_time = time.time()
# Remove from Redis
if self.redis_client:
# Remove from channel's client set
self.redis_client.srem(self.client_set_key, client_id)
# Delete individual client key
client_key = f"ts_proxy:client:{self.channel_id}:{client_id}"
self.redis_client.delete(client_key)
# Get remaining clients across all workers
total_clients = self.get_total_client_count()
logging.info(f"Client disconnected: {client_id} (local: {len(self.clients)}, total: {total_clients})")
return len(self.clients)
def get_client_count(self):
"""Get current client count"""
"""Get local client count"""
with self.lock:
return len(self.clients)
def get_total_client_count(self):
"""Get total client count across all workers"""
if not self.redis_client:
return len(self.clients)
try:
# Count members in the client set
return self.redis_client.scard(self.client_set_key) or 0
except Exception as e:
logging.error(f"Error getting total client count: {e}")
return len(self.clients) # Fall back to local count
def refresh_client_ttl(self):
"""Refresh TTL for active clients to prevent expiration"""
if not self.redis_client:
return
try:
# Refresh TTL for all clients belonging to this worker
for client_id in self.clients:
client_key = f"ts_proxy:client:{self.channel_id}:{client_id}"
self.redis_client.expire(client_key, self.client_ttl)
# Refresh TTL on the set itself
self.redis_client.expire(self.client_set_key, self.client_ttl)
except Exception as e:
logging.error(f"Error refreshing client TTL: {e}")
class StreamFetcher:
"""Handles stream data fetching"""
@ -810,8 +879,8 @@ class ProxyServer:
buffer = StreamBuffer(channel_id=channel_id, redis_client=self.redis_client)
self.stream_buffers[channel_id] = buffer
# Create client manager
client_manager = ClientManager()
# Create client manager with channel_id and redis_client
client_manager = ClientManager(channel_id=channel_id, redis_client=self.redis_client)
self.client_managers[channel_id] = client_manager
return True
@ -831,8 +900,8 @@ class ProxyServer:
buffer = StreamBuffer(channel_id=channel_id, redis_client=self.redis_client)
self.stream_buffers[channel_id] = buffer
# Create client manager
client_manager = ClientManager()
# Create client manager with channel_id and redis_client
client_manager = ClientManager(channel_id=channel_id, redis_client=self.redis_client)
self.client_managers[channel_id] = client_manager
return True
@ -850,8 +919,8 @@ class ProxyServer:
logging.debug(f"Created StreamManager for channel {channel_id}")
self.stream_managers[channel_id] = stream_manager
# Create client manager
client_manager = ClientManager()
# Create client manager with channel_id and redis_client
client_manager = ClientManager(channel_id=channel_id, redis_client=self.redis_client)
self.client_managers[channel_id] = client_manager
# Set channel activity key (separate from lock key)
@ -892,10 +961,11 @@ class ProxyServer:
# Only stop the actual stream manager if we're the owner
if self.am_i_owner(channel_id):
logging.info(f"This worker ({self.worker_id}) is the owner - will close provider connection")
if channel_id in self.stream_managers:
stream_manager = self.stream_managers[channel_id]
# Signal thread to stop and close resources
# Signal thread to stop and close resources using the proper stop method
if hasattr(stream_manager, 'stop'):
stream_manager.stop()
else:
@ -903,27 +973,63 @@ class ProxyServer:
if hasattr(stream_manager, '_close_socket'):
stream_manager._close_socket()
# Look for the thread and wait for it to finish
stream_thread_name = f"stream-{channel_id}"
stream_thread = None
for thread in threading.enumerate():
if thread.name == stream_thread_name:
stream_thread = thread
break
if stream_thread and stream_thread.is_alive():
logging.info(f"Waiting for stream thread to terminate")
try:
# Very short timeout to prevent hanging the app
stream_thread.join(timeout=2.0)
if stream_thread.is_alive():
logging.warning(f"Stream thread did not terminate within timeout")
except RuntimeError:
logging.debug("Could not join stream thread (may be current thread)")
# Release ownership
self.release_ownership(channel_id)
logging.info(f"Released ownership of channel {channel_id}")
else:
logging.info(f"This worker ({self.worker_id}) is not the owner - cleaning local resources only")
# Always clean up local resources
if channel_id in self.stream_managers:
del self.stream_managers[channel_id]
logging.info(f"Removed stream manager for channel {channel_id}")
if channel_id in self.stream_buffers:
del self.stream_buffers[channel_id]
logging.info(f"Removed stream buffer for channel {channel_id}")
if channel_id in self.client_managers:
del self.client_managers[channel_id]
logging.info(f"Removed client manager for channel {channel_id}")
# Clean up shared Redis data only if no other workers are using this channel
# Clean up Redis data
if self.redis_client:
# Check if any worker still owns this channel
if not self.redis_client.exists(f"ts_proxy:channel:{channel_id}:owner"):
# No owner, safe to remove Redis data
self.redis_client.delete(f"ts_proxy:channel:{channel_id}")
self.redis_client.delete(f"ts_proxy:active_channel:{channel_id}")
logging.info(f"Removed Redis data for channel {channel_id}")
# Clean up Redis metadata and index keys
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
self.redis_client.delete(metadata_key)
# Clean up activity key
activity_key = f"ts_proxy:active_channel:{channel_id}"
self.redis_client.delete(activity_key)
# Clean up buffer index
buffer_index_key = f"ts_proxy:buffer:{channel_id}:index"
self.redis_client.delete(buffer_index_key)
# Clean up last data key
last_data_key = f"ts_proxy:channel:{channel_id}:last_data"
self.redis_client.delete(last_data_key)
logging.info(f"Removed Redis data for channel {channel_id}")
return True
except Exception as e:
@ -956,39 +1062,38 @@ class ProxyServer:
def _start_cleanup_thread(self):
"""Start background thread to maintain ownership and clean up resources"""
def cleanup_task():
# Initial delay to let initialization complete
time.sleep(10)
while True:
try:
# Sleep first
time.sleep(self.cleanup_interval)
# Extend ownership for channels we own
for channel_id in list(self.stream_managers.keys()):
if self.am_i_owner(channel_id):
self.extend_ownership(channel_id)
# Owner should check total clients across all workers
if channel_id in self.client_managers:
client_manager = self.client_managers[channel_id]
total_clients = client_manager.get_total_client_count()
# If no clients anywhere, stop the channel
if total_clients == 0:
logging.info(f"No clients left for channel {channel_id} across all workers, stopping channel")
self.stop_channel(channel_id)
else:
# Refresh client TTLs for this worker's clients
client_manager.refresh_client_ttl()
logging.debug(f"Channel {channel_id}: {total_clients} total clients across all workers")
# Check for inactive channels but ONLY those we own
channels_to_stop = []
# Non-owner workers just refresh their client TTLs
for channel_id, client_manager in self.client_managers.items():
# Only stop channels we own and that have no clients
if self.am_i_owner(channel_id) and client_manager.get_client_count() == 0:
# Make sure it's been inactive for a while
if hasattr(client_manager, 'last_active_time'):
inactive_seconds = time.time() - client_manager.last_active_time
if inactive_seconds > 30: # 30 seconds grace period
channels_to_stop.append(channel_id)
else:
channels_to_stop.append(channel_id)
for channel_id in channels_to_stop:
logging.info(f"Auto-stopping inactive channel {channel_id}")
self.stop_channel(channel_id)
if not self.am_i_owner(channel_id):
client_manager.refresh_client_ttl()
except Exception as e:
logging.error(f"Error in cleanup thread: {e}")
# Sleep at the end
time.sleep(self.cleanup_interval)
thread = threading.Thread(target=cleanup_task, daemon=True)
thread.name = "ts-proxy-cleanup"
thread.start()

View file

@ -29,7 +29,7 @@ proxy_server = ProxyServer()
@csrf_exempt
@require_http_methods(["POST"])
def initialize_stream(request, channel_id):
"""Initialize a new stream channel with worker coordination"""
"""Initialize a new stream channel with initialization-based ownership"""
try:
data = json.loads(request.body)
url = data.get('url')
@ -39,7 +39,7 @@ def initialize_stream(request, channel_id):
# Get optional user_agent from request
user_agent = data.get('user_agent')
# Try to initialize the channel, potentially as owner
# Try to acquire ownership and create connection
success = proxy_server.initialize_channel(url, channel_id, user_agent)
if not success:
return JsonResponse({'error': 'Failed to initialize channel'}, status=500)
@ -62,25 +62,15 @@ def initialize_stream(request, channel_id):
'error': 'Failed to connect'
}, status=502)
time.sleep(0.1)
else:
# Wait for buffer to appear in Redis
wait_start = time.time()
while True:
# Check if any buffer index exists in Redis
if proxy_server.redis_client.exists(f"ts_proxy:buffer:{channel_id}:index"):
break
if time.time() - wait_start > Config.CONNECTION_TIMEOUT:
return JsonResponse({'error': 'Timeout waiting for stream to start'}, status=504)
time.sleep(0.1)
# Return success response with owner status
return JsonResponse({
'message': 'Stream initialized and connected',
'channel': channel_id,
'url': url,
'owner': proxy_server.am_i_owner(channel_id)
})
except json.JSONDecodeError:
return JsonResponse({'error': 'Invalid JSON'}, status=400)
except Exception as e:
@ -90,27 +80,64 @@ def initialize_stream(request, channel_id):
@csrf_exempt
@require_http_methods(["GET"])
def stream_ts(request, channel_id):
"""Stream TS data to client with multi-worker support"""
# First check if channel exists in local memory or Redis
"""Stream TS data to client with redis-based client tracking"""
# Check if channel exists
if channel_id not in proxy_server.stream_buffers:
# Not in local memory, check Redis
if not proxy_server.check_if_channel_exists(channel_id):
if not proxy_server.check_if_channel_exists(channel_id):
return JsonResponse({'error': 'Channel not found'}, status=404)
# Channel exists in Redis but not in this worker, initialize it (buffer only, no stream)
# Channel exists in Redis but not in this worker, initialize it (buffer only)
if not proxy_server.initialize_channel(None, channel_id):
return JsonResponse({'error': 'Failed to initialize channel'}, status=500)
# Now we should have a buffer to read from
def generate():
client_id = f"client_{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
stream_start_time = time.time()
bytes_sent = 0
chunks_sent = 0
try:
logger.info(f"[{client_id}] New client connected to channel {channel_id}")
# Add client to manager
client_manager = proxy_server.client_managers[channel_id]
client_manager.add_client(client_id)
client_count = client_manager.add_client(client_id)
# If this is the first client, try to acquire ownership
if client_count == 1 and not proxy_server.am_i_owner(channel_id):
if proxy_server.try_acquire_ownership(channel_id):
logger.info(f"[{client_id}] First client, acquiring channel ownership")
# Get channel metadata from Redis
if proxy_server.redis_client:
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
url_bytes = proxy_server.redis_client.hget(metadata_key, "url")
ua_bytes = proxy_server.redis_client.hget(metadata_key, "user_agent")
url = url_bytes.decode('utf-8') if url_bytes else None
user_agent = ua_bytes.decode('utf-8') if ua_bytes else None
if url:
# Create and start stream connection
from .server import StreamManager # Import here to avoid circular import
logger.info(f"[{client_id}] Creating stream connection for URL: {url}")
buffer = proxy_server.stream_buffers[channel_id]
stream_manager = StreamManager(url, buffer, user_agent=user_agent)
proxy_server.stream_managers[channel_id] = stream_manager
thread = threading.Thread(target=stream_manager.run, daemon=True)
thread.name = f"stream-{channel_id}"
thread.start()
# Wait briefly for connection
wait_start = time.time()
while not stream_manager.connected:
if time.time() - wait_start > Config.CONNECTION_TIMEOUT:
break
time.sleep(0.1)
# Get buffer - stream manager may not exist in this worker
buffer = proxy_server.stream_buffers.get(channel_id)
@ -240,28 +267,16 @@ def stream_ts(request, channel_id):
except Exception as e:
logger.error(f"[{client_id}] Stream error: {e}", exc_info=True)
finally:
# Clean up client
# Client cleanup - simpler now since owner tracks all clients
elapsed = time.time() - stream_start_time
remaining_clients = 0
local_clients = 0
if channel_id in proxy_server.client_managers:
remaining_clients = proxy_server.client_managers[channel_id].remove_client(client_id)
logger.info(f"[{client_id}] Disconnected after {elapsed:.2f}s, {bytes_sent/1024:.1f}KB in {chunks_sent} chunks ({remaining_clients} clients left)")
# If no clients left, schedule shutdown
if remaining_clients == 0:
logger.info(f"No clients left for channel {channel_id}, scheduling shutdown")
def delayed_shutdown():
time.sleep(5) # 5-second grace period
if channel_id in proxy_server.client_managers and \
proxy_server.client_managers[channel_id].get_client_count() == 0:
logger.info(f"Shutting down channel {channel_id} as no clients connected")
proxy_server.stop_channel(channel_id)
shutdown_thread = threading.Thread(target=delayed_shutdown)
shutdown_thread.daemon = True
shutdown_thread.start()
local_clients = proxy_server.client_managers[channel_id].remove_client(client_id)
total_clients = proxy_server.client_managers[channel_id].get_total_client_count()
logger.info(f"[{client_id}] Disconnected after {elapsed:.2f}s, {bytes_sent/1024:.1f}KB in {chunks_sent} chunks (local: {local_clients}, total: {total_clients})")
# Create streaming response
response = StreamingHttpResponse(generate(), content_type='video/MP2T')
response['Cache-Control'] = 'no-cache, no-store'