From f549957c0bde1fdab03b0c696aa4304a8e324a3d Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 19 Mar 2026 21:55:00 +0000 Subject: [PATCH] perf(satellite): compute ground tracks in thread pool, not inline Ground track computation (90 Skyfield points per satellite) was blocking the 1Hz tracker loop on every cache miss. On cold start with multiple tracked satellites this could stall the SSE stream for several seconds. Tracks are now computed in a 2-worker ThreadPoolExecutor. The tracker loop emits position without groundTrack on cache miss; clients retain the previous track via SSE merge until the new one is ready. --- routes/satellite.py | 51 ++++++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/routes/satellite.py b/routes/satellite.py index 1343685..11b797e 100644 --- a/routes/satellite.py +++ b/routes/satellite.py @@ -52,6 +52,11 @@ _tle_cache = dict(TLE_SATELLITES) # TTL is 1800 seconds (30 minutes) _track_cache: dict = {} _TRACK_CACHE_TTL = 1800 + +# Thread pool for background ground-track computation (non-blocking from 1Hz tracker loop) +from concurrent.futures import ThreadPoolExecutor as _ThreadPoolExecutor +_track_executor = _ThreadPoolExecutor(max_workers=2, thread_name_prefix='sat-track') +_track_in_progress: set = set() # cache keys currently being computed _pass_cache: dict = {} _PASS_CACHE_TTL = 300 @@ -253,27 +258,43 @@ def _start_satellite_tracker(): 'altitude': float(subpoint.elevation.km), } - # Ground track with caching (90 points, TTL 1800s) + # Ground track with caching (90 points, TTL 1800s). + # If the cache is stale, kick off background computation so the + # 1Hz tracker loop is not blocked. The client retains the previous + # track via SSE merge until the new one arrives next tick. cache_key_track = (sat_name, tle1[:20]) cached = _track_cache.get(cache_key_track) if cached and (time.time() - cached[1]) < _TRACK_CACHE_TTL: pos['groundTrack'] = cached[0] - else: - track = [] - for minutes_offset in range(-45, 46, 1): - t_point = ts.utc(now_dt + timedelta(minutes=minutes_offset)) + elif cache_key_track not in _track_in_progress: + _track_in_progress.add(cache_key_track) + _sat_ref = satellite + _ts_ref = ts + _now_dt_ref = now_dt + + def _compute_track(_sat=_sat_ref, _ts=_ts_ref, _now_dt=_now_dt_ref, _key=cache_key_track): try: - geo = satellite.at(t_point) - sp = wgs84.subpoint(geo) - track.append({ - 'lat': float(sp.latitude.degrees), - 'lon': float(sp.longitude.degrees), - 'past': minutes_offset < 0, - }) + track = [] + for minutes_offset in range(-45, 46, 1): + t_point = _ts.utc(_now_dt + timedelta(minutes=minutes_offset)) + try: + geo = _sat.at(t_point) + sp = wgs84.subpoint(geo) + track.append({ + 'lat': float(sp.latitude.degrees), + 'lon': float(sp.longitude.degrees), + 'past': minutes_offset < 0, + }) + except Exception: + continue + _track_cache[_key] = (track, time.time()) except Exception: - continue - _track_cache[cache_key_track] = (track, time.time()) - pos['groundTrack'] = track + pass + finally: + _track_in_progress.discard(_key) + + _track_executor.submit(_compute_track) + # groundTrack omitted this tick; frontend retains prior value positions.append(pos) except Exception: