bugfix on service discovery
This commit is contained in:
@@ -98,32 +98,88 @@ class LeaderClient:
|
||||
self._on_change_callbacks: list[Callable[[], None]] = []
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def get_leader_info(self) -> Optional[LeaderLockInfo]:
|
||||
"""Read leader info from the info file."""
|
||||
# URL caching
|
||||
self._cached_urls: Optional[URLsResponse] = None
|
||||
self._urls_cache_time: float = 0
|
||||
self._urls_cache_ttl: float = 5.0 # 5 seconds
|
||||
|
||||
def _get_api_url_from_file(self) -> Optional[str]:
|
||||
"""Read API URL from file (plain text format)."""
|
||||
try:
|
||||
with open(self.info_file_path, 'r') as f:
|
||||
content = f.read()
|
||||
return content.strip() or None
|
||||
except FileNotFoundError:
|
||||
print(f"[LeaderClient] Leader info file not found: {self.info_file_path}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[LeaderClient] Error reading API URL from file: {e}")
|
||||
return None
|
||||
|
||||
data = json.loads(content)
|
||||
def _fetch_urls(self, api_url: str) -> Optional[URLsResponse]:
|
||||
"""Fetch URLs from meta-core /urls API with caching."""
|
||||
import time
|
||||
|
||||
# Check cache
|
||||
now = time.time()
|
||||
if self._cached_urls and (now - self._urls_cache_time) < self._urls_cache_ttl:
|
||||
return self._cached_urls
|
||||
|
||||
try:
|
||||
url = f"{api_url}/urls"
|
||||
req = Request(url, headers={'Accept': 'application/json'})
|
||||
with urlopen(req, timeout=5) as response:
|
||||
if response.status != 200:
|
||||
print(f"[LeaderClient] Failed to fetch URLs: {response.status}")
|
||||
return None
|
||||
|
||||
data = json.loads(response.read().decode())
|
||||
|
||||
self._cached_urls = URLsResponse(
|
||||
hostname=data.get('hostname', ''),
|
||||
base_url=data.get('baseUrl', ''),
|
||||
api_url=data.get('apiUrl', ''),
|
||||
redis_url=data.get('redisUrl', ''),
|
||||
webdav_url=data.get('webdavUrl', ''),
|
||||
is_leader=data.get('isLeader', False)
|
||||
)
|
||||
self._urls_cache_time = now
|
||||
return self._cached_urls
|
||||
|
||||
except URLError as e:
|
||||
print(f"[LeaderClient] Error calling /urls API: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[LeaderClient] Error fetching URLs: {e}")
|
||||
return None
|
||||
|
||||
def get_leader_info(self) -> Optional[LeaderLockInfo]:
|
||||
"""Read leader info from file and /urls API."""
|
||||
try:
|
||||
# Read API URL from file (plain text)
|
||||
api_url = self._get_api_url_from_file()
|
||||
if not api_url:
|
||||
return None
|
||||
|
||||
# Fetch full info from /urls API
|
||||
urls = self._fetch_urls(api_url)
|
||||
if not urls:
|
||||
return None
|
||||
|
||||
# Convert URLsResponse to LeaderLockInfo
|
||||
import time
|
||||
self.leader_info = LeaderLockInfo(
|
||||
hostname=data.get('hostname', 'unknown'),
|
||||
base_url=data.get('baseUrl', ''),
|
||||
api_url=data.get('apiUrl', ''),
|
||||
redis_url=data.get('redisUrl', ''),
|
||||
webdav_url=data.get('webdavUrl', ''),
|
||||
timestamp=data.get('timestamp', 0),
|
||||
pid=data.get('pid', 0)
|
||||
hostname=urls.hostname,
|
||||
base_url=urls.base_url,
|
||||
api_url=urls.api_url,
|
||||
redis_url=urls.redis_url,
|
||||
webdav_url=urls.webdav_url,
|
||||
timestamp=int(time.time() * 1000),
|
||||
pid=0 # Unknown for remote leader
|
||||
)
|
||||
|
||||
return self.leader_info
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"[LeaderClient] Leader info file not found: {self.info_file_path}")
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[LeaderClient] Error parsing leader info: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[LeaderClient] Error reading leader info: {e}")
|
||||
return None
|
||||
@@ -148,40 +204,15 @@ class LeaderClient:
|
||||
# First try using configured meta_core_url
|
||||
api_url = self.meta_core_url
|
||||
|
||||
# Fall back to reading from leader info
|
||||
# Fall back to reading from file
|
||||
if not api_url:
|
||||
info = self.get_leader_info()
|
||||
api_url = info.api_url if info else None
|
||||
api_url = self._get_api_url_from_file()
|
||||
|
||||
if not api_url:
|
||||
print("[LeaderClient] No meta-core API URL available")
|
||||
return None
|
||||
|
||||
try:
|
||||
url = f"{api_url}/urls"
|
||||
req = Request(url, headers={'Accept': 'application/json'})
|
||||
with urlopen(req, timeout=5) as response:
|
||||
if response.status != 200:
|
||||
print(f"[LeaderClient] Failed to get URLs: {response.status}")
|
||||
return None
|
||||
|
||||
data = json.loads(response.read().decode())
|
||||
|
||||
return URLsResponse(
|
||||
hostname=data.get('hostname', ''),
|
||||
base_url=data.get('baseUrl', ''),
|
||||
api_url=data.get('apiUrl', ''),
|
||||
redis_url=data.get('redisUrl', ''),
|
||||
webdav_url=data.get('webdavUrl', ''),
|
||||
is_leader=data.get('isLeader', False)
|
||||
)
|
||||
|
||||
except URLError as e:
|
||||
print(f"[LeaderClient] Error calling /urls API: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[LeaderClient] Error getting URLs: {e}")
|
||||
return None
|
||||
return self._fetch_urls(api_url)
|
||||
|
||||
def wait_for_leader(self, timeout_ms: int = 30000) -> LeaderLockInfo:
|
||||
"""Wait for leader info to be available."""
|
||||
@@ -232,7 +263,11 @@ class LeaderClient:
|
||||
|
||||
def _on_file_change(self) -> None:
|
||||
"""Handle file change event."""
|
||||
print("[LeaderClient] Leader info changed, reloading...")
|
||||
print("[LeaderClient] Leader info changed, invalidating cache...")
|
||||
# Invalidate cache to force fresh API call
|
||||
self._cached_urls = None
|
||||
self._urls_cache_time = 0
|
||||
|
||||
self.get_leader_info()
|
||||
self._notify_change()
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
Service Discovery for meta-stremio.
|
||||
|
||||
Python implementation of the service discovery pattern used in meta-sort.
|
||||
Each service registers itself in /meta-core/services/{service-name}.json
|
||||
Python implementation of the service discovery pattern used in meta-sort/meta-fuse.
|
||||
Each service registers itself in /meta-core/services/{service-name}-{hostname}.json
|
||||
and can discover other registered services.
|
||||
|
||||
Features:
|
||||
@@ -18,7 +18,7 @@ import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Callable
|
||||
@@ -35,17 +35,23 @@ BASE_URL = os.environ.get('BASE_URL', '')
|
||||
|
||||
@dataclass
|
||||
class ServiceInfo:
|
||||
"""Service registration info stored in JSON file."""
|
||||
"""
|
||||
Service registration info stored in JSON file.
|
||||
|
||||
Simplified format matching TypeScript ServiceInfo interface:
|
||||
- name: Service name (e.g., 'meta-stremio')
|
||||
- hostname: Container/host hostname
|
||||
- baseUrl: Base URL for the service (e.g., 'http://localhost:8182')
|
||||
- status: Current status ('running' | 'stale' | 'stopped')
|
||||
- lastHeartbeat: ISO timestamp of last heartbeat
|
||||
- role: Optional role ('leader' | 'follower') - only used by meta-core
|
||||
"""
|
||||
name: str
|
||||
version: str
|
||||
api: str # HTTP API URL (e.g., http://10.0.1.50:7000)
|
||||
status: str = 'starting' # starting | running | stopping | stopped
|
||||
pid: int = 0
|
||||
hostname: str = ''
|
||||
startedAt: str = ''
|
||||
hostname: str
|
||||
baseUrl: str
|
||||
status: str = 'running' # running | stale | stopped
|
||||
lastHeartbeat: str = ''
|
||||
capabilities: List[str] = field(default_factory=list)
|
||||
endpoints: Dict[str, str] = field(default_factory=dict)
|
||||
role: Optional[str] = None # "leader", "follower", or None (for non-meta-core services)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
@@ -56,7 +62,7 @@ class ServiceDiscovery:
|
||||
"""
|
||||
Service Discovery using shared filesystem.
|
||||
|
||||
Each service registers itself in /meta-core/services/{service-name}.json
|
||||
Each service registers itself in /meta-core/services/{service-name}-{hostname}.json
|
||||
and can discover other registered services.
|
||||
"""
|
||||
|
||||
@@ -74,21 +80,20 @@ class ServiceDiscovery:
|
||||
):
|
||||
self.meta_core_path = meta_core_path or META_CORE_PATH
|
||||
self.service_name = service_name or SERVICE_NAME
|
||||
self.version = version or SERVICE_VERSION
|
||||
# Use base_url if provided, then api_url, then env BASE_URL, then auto-detect
|
||||
self.api_url = base_url or api_url or BASE_URL or self._get_default_api_url()
|
||||
self.capabilities = capabilities or ['streaming', 'transcoding', 'hls']
|
||||
self.endpoints = endpoints or {
|
||||
'health': '/health',
|
||||
'manifest': '/manifest.json',
|
||||
'dashboard': '/',
|
||||
'api': '/api',
|
||||
}
|
||||
self.base_url = base_url or api_url or BASE_URL or self._get_default_api_url()
|
||||
self.heartbeat_interval = heartbeat_interval
|
||||
self.stale_threshold = stale_threshold
|
||||
|
||||
# Keep for backwards compatibility but not used in simplified format
|
||||
self._version = version or SERVICE_VERSION
|
||||
self._capabilities = capabilities or []
|
||||
self._endpoints = endpoints or {}
|
||||
|
||||
self.current_hostname = socket.gethostname()
|
||||
self.services_dir = os.path.join(self.meta_core_path, 'services')
|
||||
self.service_file_path = os.path.join(self.services_dir, f'{self.service_name}.json')
|
||||
# Use hostname-based file naming like TypeScript services
|
||||
self.service_file_path = os.path.join(self.services_dir, f'{self.service_name}-{self.current_hostname}.json')
|
||||
|
||||
self._heartbeat_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
@@ -116,20 +121,14 @@ class ServiceDiscovery:
|
||||
"""Ensure services directory exists."""
|
||||
Path(self.services_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _build_service_info(self, status: str = 'starting') -> ServiceInfo:
|
||||
"""Build service info for this service."""
|
||||
now = datetime.utcnow().isoformat() + 'Z'
|
||||
def _build_service_info(self, status: str = 'running') -> ServiceInfo:
|
||||
"""Build service info for this service (simplified format)."""
|
||||
return ServiceInfo(
|
||||
name=self.service_name,
|
||||
version=self.version,
|
||||
api=self.api_url,
|
||||
hostname=self.current_hostname,
|
||||
baseUrl=self.base_url,
|
||||
status=status,
|
||||
pid=os.getpid(),
|
||||
hostname=socket.gethostname(),
|
||||
startedAt=now,
|
||||
lastHeartbeat=now,
|
||||
capabilities=self.capabilities,
|
||||
endpoints=self.endpoints
|
||||
lastHeartbeat=datetime.utcnow().isoformat() + 'Z'
|
||||
)
|
||||
|
||||
def register(self) -> None:
|
||||
@@ -236,22 +235,34 @@ class ServiceDiscovery:
|
||||
return True
|
||||
|
||||
def discover_service(self, name: str) -> Optional[dict]:
|
||||
"""Discover a service by name."""
|
||||
service_path = os.path.join(self.services_dir, f'{name}.json')
|
||||
|
||||
"""
|
||||
Discover a service by name.
|
||||
Looks for files matching pattern: {name}-*.json (hostname-based naming)
|
||||
Falls back to exact match {name}.json for backwards compatibility.
|
||||
"""
|
||||
try:
|
||||
with open(service_path, 'r') as f:
|
||||
info = json.load(f)
|
||||
|
||||
# Check if service is alive
|
||||
if self._is_service_stale(info):
|
||||
print(f'[ServiceDiscovery] Service {name} appears stale')
|
||||
if not os.path.exists(self.services_dir):
|
||||
return None
|
||||
|
||||
return info
|
||||
# First try exact match for backward compatibility
|
||||
exact_path = os.path.join(self.services_dir, f'{name}.json')
|
||||
if os.path.exists(exact_path):
|
||||
with open(exact_path, 'r') as f:
|
||||
info = json.load(f)
|
||||
if not self._is_service_stale(info) and info.get('status') == 'running':
|
||||
return info
|
||||
|
||||
# Search for hostname-based files: name-*.json
|
||||
for filename in os.listdir(self.services_dir):
|
||||
if filename.startswith(f'{name}-') and filename.endswith('.json'):
|
||||
filepath = os.path.join(self.services_dir, filename)
|
||||
with open(filepath, 'r') as f:
|
||||
info = json.load(f)
|
||||
if not self._is_service_stale(info) and info.get('status') == 'running':
|
||||
return info
|
||||
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f'[ServiceDiscovery] Error discovering {name}: {e}')
|
||||
return None
|
||||
@@ -259,6 +270,8 @@ class ServiceDiscovery:
|
||||
def discover_all_services(self) -> List[dict]:
|
||||
"""Discover all registered services."""
|
||||
services = []
|
||||
seen_names = set()
|
||||
meta_core_instances = [] # Collect all meta-core instances to pick leader
|
||||
|
||||
try:
|
||||
if not os.path.exists(self.services_dir):
|
||||
@@ -268,11 +281,40 @@ class ServiceDiscovery:
|
||||
if not filename.endswith('.json'):
|
||||
continue
|
||||
|
||||
name = filename.replace('.json', '')
|
||||
service = self.discover_service(name)
|
||||
filepath = os.path.join(self.services_dir, filename)
|
||||
try:
|
||||
with open(filepath, 'r') as f:
|
||||
info = json.load(f)
|
||||
|
||||
if service and service.get('status') == 'running':
|
||||
services.append(service)
|
||||
# Skip stale or non-running services
|
||||
if self._is_service_stale(info):
|
||||
continue
|
||||
if info.get('status') != 'running':
|
||||
continue
|
||||
|
||||
name = info.get('name', '')
|
||||
|
||||
# Special handling for meta-core: collect all instances
|
||||
# to pick the leader later (filesystem order is unreliable)
|
||||
if name == 'meta-core':
|
||||
meta_core_instances.append(info)
|
||||
continue
|
||||
|
||||
# Deduplicate by service name (keep first found)
|
||||
if name and name not in seen_names:
|
||||
seen_names.add(name)
|
||||
services.append(info)
|
||||
|
||||
except Exception as e:
|
||||
print(f'[ServiceDiscovery] Failed to read {filename}: {e}')
|
||||
|
||||
# Add meta-core: prefer leader, fallback to first instance
|
||||
if meta_core_instances:
|
||||
leader = next(
|
||||
(s for s in meta_core_instances if s.get('role') == 'leader'),
|
||||
meta_core_instances[0] # Fallback if no leader found
|
||||
)
|
||||
services.append(leader)
|
||||
|
||||
except Exception as e:
|
||||
print(f'[ServiceDiscovery] Error discovering services: {e}')
|
||||
@@ -285,8 +327,12 @@ class ServiceDiscovery:
|
||||
if not service:
|
||||
return False
|
||||
|
||||
health_endpoint = service.get('endpoints', {}).get('health', '/health')
|
||||
url = f"{service['api']}{health_endpoint}"
|
||||
# Use baseUrl (new format) or api (legacy format)
|
||||
base_url = service.get('baseUrl', '') or service.get('api', '')
|
||||
if not base_url:
|
||||
return False
|
||||
|
||||
url = f"{base_url}/health"
|
||||
|
||||
try:
|
||||
with urlopen(url, timeout=5) as response:
|
||||
@@ -312,7 +358,7 @@ class ServiceDiscovery:
|
||||
self.register()
|
||||
self.update_status('running')
|
||||
self.start_heartbeat()
|
||||
print(f'[ServiceDiscovery] Started {self.service_name} at {self.api_url}')
|
||||
print(f'[ServiceDiscovery] Started {self.service_name}-{self.current_hostname} at {self.base_url}')
|
||||
except Exception as e:
|
||||
print(f'[ServiceDiscovery] Registration failed (read-only?): {e}')
|
||||
print(f'[ServiceDiscovery] Running in discovery-only mode')
|
||||
|
||||
Reference in New Issue
Block a user