Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Cache System - Server-Side Management

Overview

The cache system is managed entirely server-side with per-job TTL-based expiration and intelligent cache invalidation when location parameters change.

The key design principle is selective refresh: the background scheduler polls every 5 minutes, but each job only executes when its own TTL has expired. Heavy computations (eclipses, planetary events) run at most once a day, while time-sensitive data (aurora, sidereal) refreshes hourly. This dramatically reduces CPU and memory pressure on low-resource machines.

Multi-location model

Caches are split into two buckets (full details: docs/LOCATIONS.md):

  • Location-scoped caches keep one slot per location preset, keyed by the preset’s immutable id. On-disk keys in astro_cache.json are "<name>:<location_id>". Access goes through cache_store.get_location_cache_entry(name, location_id), load_location_cache(...) and update_location_cache(...) - the pre-v1.2 module-level singletons (_sun_report_cache, …) no longer exist for these. Scoped names: moon_report, dark_window, moon_planner, sun_report, best_window_strict/practical/illumination, solar_eclipse, lunar_eclipse, horizon_graph, aurora, iss_passes, css_passes, planetary_events, special_phenomena, solar_system_events, sidereal_time, seeing_forecast, weather_forecast, astro_weather (single source of truth: cache_store.LOCATION_SCOPED_CACHE_TTLS).
  • Global caches keep the single-slot module-level shape: spaceflight_launches, spaceflight_astronauts, spaceflight_events, iers, the AllSky health cache (its sensor data arrives over MQTT, not through a cache job), and the version-check cache. The ISS/CSS TLE fetch is also global (its own on-disk cache inside space/iss_passes.py/space/css_passes.py); only pass-visibility math is per location.

The scheduler runs each location-scoped job once per scheduler location (install default + presets attributed to at least one user), gated by the (job, location) TTL. Job × location units share the same fixed-size thread pool (≤6 workers), so total concurrent API calls never exceed the pre-v1.2 ceiling. Metrics rows for non-default locations use the "<job>@<location-slug>" key format.

Key Features

Server-Side Only Management

  • No browser-side refresh required - F5 works normally
  • All cache calculations happen on the server
  • Cache is automatically refreshed on schedule (selective per-job TTL)

Per-Job TTL Configuration

Each cache job has an individual TTL defined in backend/utils/constants.py:

JobTTLRationale
moon_report2 hoursMoon phase changes gradually
dark_window2 hoursDerived from moon report
moon_planner2 hours7-night forecast, daily precision is sufficient
sun_report6 hoursSunrise/sunset changes slowly
best_window3 hours“Best window for tonight”
solar_eclipse24 hoursNext event is months away
lunar_eclipse24 hoursNext event is months away
horizon_graph6 hoursDaily arc of sun/moon
aurora1 hourGeomagnetic forecast updates hourly
iss_passes6 hours20-day prediction window, stable
css_passes6 hours20-day prediction window, stable (CSS NORAD 48274)
planetary_events24 hours365-day planetary forecast
special_phenomena24 hoursAnnual events (equinoxes, solstices…)
solar_system_events24 hoursAnnual events (meteor showers, comets…)
sidereal_time1 hourHourly precision is sufficient
seeing_forecast6 hours7Timer API update frequency
weather_forecast1 hourSuitable for UI display
astro_weather30 minJet-stream-aware astro analysis; source of the app-wide observation_score
spaceflight_launches2 hoursLaunch Library API budget and cadence
spaceflight_astronauts6 hoursCrew roster changes are less frequent
spaceflight_events2 hoursEvent feed changes but does not require minute-level polling
iers21 daysEarth-orientation data is long-lived

Note on Weather Data:

  • UI Weather Forecast: Cached for 1 hour (suitable for display)
  • SkyTonight Conditions: NOT cached - always fetches fresh real-time data
    • SkyTonight requires accurate current conditions (temperature, pressure, humidity)
    • Each SkyTonight run bypasses cache to get live conditions from Open-Meteo API

Selective Refresh (Resource Efficiency)

fully_initialize_caches() in cache/cache_updater.py now:

  1. Syncs each in-memory cache entry from the shared JSON file (gets persisted timestamp)
  2. Checks each job’s TTL individually
  3. Skips jobs whose TTL has not yet expired
  4. Only runs stale jobs - often just 2-4 jobs per poll cycle

On a typical steady-state machine, most 5-minute poll cycles skip all jobs because TTLs are still valid. Hourly-class jobs run only when their own TTL expires; daily jobs run at most once every 24 hours.

Automatic Location Change Detection

Location change detection is per preset (v1.2). When any of these parameters change on a preset, that preset’s caches only are immediately reset (timestamps zeroed), forcing a refresh of its slots on the next scheduler cycle - other presets’ caches are untouched:

  • Latitude
  • Longitude
  • Elevation
  • Timezone

Signatures are tracked per preset id in data/cache/location_cache.json (cache_store.has_location_changed(preset) / update_location_config(preset) / reset_caches_for_location(id)). Deleting a preset drops its slots and signature (drop_location_caches(id)).

Background Cache Scheduler

  • Runs in a dedicated daemon thread
  • Starts automatically on app/container startup
  • Updates all caches immediately on first run (all timestamps = 0)
  • Then polls every 300 seconds (5 minutes), running only expired jobs
  • Uses file locking to ensure only one scheduler runs across multiple workers

Architecture

Components

1. cache/cache_store.py - Cache Storage & Validation

  • Maintains in-memory cache entries with timestamps
  • Persists cache entries to data/cache/astro_cache.json for cross-worker visibility
  • Tracks location configuration changes
  • Provides TTL validation using per-job constants
  • New: record_cache_execution(job_name, duration_s, success) - persists per-job timing
  • New: get_cache_metrics() - returns per-job execution metrics from shared cache
  • Key functions:
    • is_cache_valid(cache_entry, ttl_seconds) - check if cache is still fresh
    • is_astronomical_cache_ready() - check if all caches are valid (uses per-job TTLs); the Open-Meteo-backed slots (weather_forecast, astro_weather) are excluded - they have a live-fetch fallback and must not gate readiness (_READINESS_LOCATION_CACHES)
    • has_location_changed(location_config) - detect location parameter changes
    • reset_all_caches() - immediately reset all astronomical caches
    • get_cache_init_status() - detailed cache status including ttls and execution_metrics

2. cache/cache_updater.py - Cache Calculation (Selective)

  • Contains all individual cache update functions
  • fully_initialize_caches() - selective: syncs timestamps, skips valid caches, records per-job timing
  • check_and_handle_config_changes() - detects and resets caches on location change

3. cache/cache_scheduler.py - Background Task Management

  • CacheScheduler class polls at 300-second interval
  • Uses file locking to prevent multiple instances
  • Calls fully_initialize_caches() which handles selective execution
  • Sets cache_ready_event after the first successful cycle

4. utils/constants.py - TTL Constants

  • CACHE_TTL_<JOB_NAME> constants define the TTL for each job
  • WEATHER_CACHE_TTL = 3600s (1 hour) for weather
  • CACHE_TTL = 1800s kept for backward compatibility only (not used for job scheduling)

5. weather/weather_utils.py - Dual Weather Client System

  • Cached Client (create_weather_client()): 1-hour HTTP-level cache for UI forecasts
  • Fresh Client (create_fresh_weather_client()): no caching, used by SkyTonight for real-time conditions

Cache Flow

App Startup
  ↓
Cache Scheduler starts
  ↓
Initial cache population (all timestamps = 0 -> all jobs run)
  ├─ Check location config
  ├─ Run all registered jobs
  └─ Record timing per job, set timestamps
  ↓
Wait 300s (5 min)
  ↓
Periodic poll
  ├─ Sync in-memory timestamps from shared JSON file
  ├─ For each job: check if TTL elapsed
  │   ├─ TTL not elapsed -> skip job
  │   └─ TTL elapsed -> run job, record duration, update timestamp
  └─ Log: "X/N jobs ran, Y/X succeeded in Z.Zs"
  ↓
Next poll (300s later)...

Configuration Changes

When location is updated via /api/config POST endpoint:

  1. System detects change in latitude, longitude, elevation, or timezone
  2. All cache timestamps zeroed (reset_all_caches())
  3. Configuration saved to file
  4. Next scheduler poll will run all 16 jobs (all timestamps = 0)
  5. Browser receives confirmation including "cache_reset": true

API Endpoints

/api/cache - Cache Status (GET)

Purpose: Informational - allows UI to display cache status and job metrics Returns:

{
  "cache_status": true,
  "in_progress": false,
  "current_step": 0,
  "total_steps": 0,
  "step_name": "",
  "progress_percent": 0,
  "details": {
    "moon_report": true,
    "sun_report": true,
    "best_window_strict": true,
    "best_window_practical": true,
    "best_window_illumination": true,
    "moon_planner": true,
    "dark_window": true,
    "solar_eclipse": true,
    "lunar_eclipse": true,
    "horizon_graph": true,
    "aurora": true,
    "iss_passes": true,
    "css_passes": true,
    "planetary_events": true,
    "special_phenomena": true,
    "solar_system_events": true,
    "sidereal_time": true,
    "seeing_forecast": true,
    "weather_forecast": true,
    "astro_weather": true,
    "all_ready": true,
    "ttls": {
      "moon_report": 7200,
      "dark_window": 7200,
      "moon_planner": 7200,
      "sun_report": 21600,
      "best_window": 10800,
      "solar_eclipse": 86400,
      "lunar_eclipse": 86400,
      "horizon_graph": 21600,
      "aurora": 3600,
      "iss_passes": 21600,
      "css_passes": 21600,
      "planetary_events": 86400,
      "special_phenomena": 86400,
      "solar_system_events": 86400,
      "sidereal_time": 3600,
      "seeing_forecast": 21600,
      "weather_forecast": 3600,
      "astro_weather": 1800,
      "spaceflight_launches": 7200,
      "spaceflight_astronauts": 21600,
      "spaceflight_events": 7200,
      "iers": 1814400
    },
    "execution_metrics": {
      "moon_report": {
        "last_run_at": "2026-05-01T20:00:01.123",
        "last_duration_s": 0.842,
        "last_success": true
      }
    }
  }
}

/api/config - Configuration Update (POST)

  • Detects if latitude, longitude, elevation, or timezone changed
  • Immediately resets all caches (timestamps to 0)
  • Returns "cache_reset": true when location changed

Data Endpoints (Astronomical Data)

These endpoints return from cache only:

  • GET /api/moon/report - Moon report
  • GET /api/moon/dark-window - Dark window
  • GET /api/moon/next-7-nights - Moon planner
  • GET /api/sun/today - Sun report
  • GET /api/tonight/best-window - Best observation window

Response codes:

  • 200: Data returned (cache is valid)
  • 202: Pending - cache being prepared (retry shortly)

Metrics Dashboard (Cache Jobs section)

The Metrics tab in the UI (admin only) includes a Cache Jobs table populated from GET /api/cache:

ColumnDescription
JobHuman-readable job name
TTLConfigured TTL for this job
StatusValid (green) / Stale (yellow) based on current TTL
Last runISO timestamp of last execution
DurationWall-clock time of last execution

A “Failed” badge appears in the Duration column if the last execution threw an exception.

Map tile cache

The maps (locations, photo map, observation log, orbital stations) load everything they draw from /api/map-tiles/... instead of a map server: utils/map_tiles.py downloads each resource once from the OpenStreetMap Foundation servers and keeps it under data/cache/map_tiles/<kind>/. The browser never contacts a third-party map server, and the map servers only see the server’s address.

KindRouteUpstreamKeptBrowser cache
Vector tiles (Shortbread)/api/map-tiles/vector/<z>/<x>/<y>.mvtvector.openstreetmap.org7 days1 day
Glyphs (label fonts)/api/map-tiles/fonts/<font>/<range>.pbfvector.openstreetmap.org30 days7 days
Sprites (icons)/api/map-tiles/sprites/<file>vector.openstreetmap.org30 days7 days
Raster tiles (PNG)/api/map-tiles/raster/<z>/<x>/<y>.pngtile.openstreetmap.org7 days1 day
  • Rendering: with WebGL, MapLibre GL JS draws the vector tiles inside a Leaflet layer (static/js/utils.js), with one style per look in static/map-styles/: light (VersaTiles Colorful), dark (VersaTiles Eclipse) and red (Eclipse recoloured to dim reds, used by the night vision theme whatever the light/dark choice, and swapped live when the theme changes). Without WebGL the maps fall back to raster tiles, recoloured for the dark variant and the red theme by CSS classes.
  • Styles: python scripts/build_map_styles.py downloads the VersaTiles styles (CC0) and rewrites their URLs to the proxy. Run it again after a new Shortbread major version, and bump _MAP_STYLES_VERSION in utils.js.
  • Outages: an expired resource is still served while the map server is unreachable.
  • Size: once the folder passes 300 MiB, the oldest files are deleted down to 240 MiB (checked every 200 downloads). Deleting the folder is always safe.
  • Fair use (tile and vector usage policies): at most 2 concurrent downloads per worker, an identifying User-Agent, resources kept at least 7 days, and a budget of 1000 uncached resources per user per 10 minutes per worker (cache hits are free) so the proxy cannot be used for bulk downloading.

Performance Impact

Benefits

  • No browser delays - F5 is instant
  • Low-resource friendly - heavy jobs (eclipses, planetary events) run at most once a day instead of every scheduler poll
  • Typical steady-state: most 5-min polls run 0 jobs (all TTLs intact); hourly-class jobs run only on expiry; daily jobs run ~once per day
  • Automatic location handling - no stale data when location changes
  • Full observability - per-job last run time and duration visible in Metrics tab

Cache Sizes (Typical)

  • Moon report: ~5-10 KB
  • Sun report: ~3-5 KB
  • Best windows: ~5-8 KB each (×3 modes)
  • Moon planner: ~20-30 KB
  • Dark window: ~1-2 KB
  • Total: ~50-100 KB in memory + shared JSON file on disk

Troubleshooting

Cache Always Shows “Pending”

  1. Check cache scheduler is running: GET /api/skytonight/scheduler/status
  2. Check server logs for errors in cache calculations
  3. Verify location configuration is complete
  4. Check Metrics -> Cache Jobs for failed jobs

Cache Not Updating After Location Change

  1. Verify location parameters actually changed
  2. Watch server logs for “Location configuration changed” message
  3. Check /api/cache shows caches as not ready (timestamps zeroed)
  4. Wait for next scheduler poll (max 5 minutes)

Multiple Cache Updates Running

  • Should not happen - file locking prevents it
  • If it does, check data/cache/cache_scheduler.lock for stale lock

SkyTonight Getting Stale Weather Conditions

  • This should NOT happen - SkyTonight always fetches fresh data via create_fresh_weather_client()
  • Each SkyTonight run bypasses the weather cache
  • If conditions seem old, check Open-Meteo API availability

Technical Implementation Details

Selective Refresh Logic (fully_initialize_caches)

for job_name, shared_key, update_fn, ttl, cache_entry in cache_jobs:
    cache_store.sync_cache_from_shared(shared_key, cache_entry)
    if cache_store.is_cache_valid(cache_entry, ttl):
        continue  # skip - still fresh
    # run the job and record timing
    t0 = time.time()
    update_fn()
    cache_store.record_cache_execution(job_name, time.time() - t0, success=True)

Execution Metrics Storage

Per-job metrics are stored under the _cache_metrics key in data/cache/astro_cache.json:

{
  "_cache_metrics": {
    "moon_report": {
      "last_run_at": "2026-05-01T20:00:01.123",
      "last_duration_s": 0.842,
      "last_success": true
    }
  }
}

Location Change Detection

# Signature-based comparison
new_signature = {"latitude": 45.5, "longitude": -73.5, "elevation": 100, "timezone": "America/Montreal"}
# If any value differs -> reset_all_caches() -> all timestamps zeroed

Adding a New Cache Job

  1. Add a CACHE_TTL_<NAME> constant in utils/constants.py with a justified value
  2. Add a _<name>_cache entry in cache/cache_store.py
  3. Add update_<name>_cache(config=None) in cache/cache_updater.py - guard with if config is None: config = load_config()
  4. Register it in the cache_jobs list in fully_initialize_caches() using partial(update_<name>_cache, config=config)
  5. Add it to is_astronomical_cache_ready() and get_cache_init_status() with its TTL constant
  6. Add it to reset_all_caches() and _write_all_astronomical_caches_to_shared()
  7. Document the TTL rationale in this file and .github/instructions/copilot.instructions.md

Computation Optimisations

To keep CPU and memory usage low on single-worker Docker deployments, the cache system applies three computation-level optimisations on each refresh cycle:

OptimisationWhereSaving
Config loaded oncefully_initialize_caches() calls load_config() once and forwards it to all update functions via functools.partialEliminates ~14 redundant disk reads per cycle
Moon caches mergedupdate_moon_caches() instantiates MoonService and calls get_report() once; both moon_report and dark_window caches are written from the same resultSaves one full MoonService.get_report() Astropy computation per cycle
Best-window single passAstroTonightService.best_windows_all_modes() runs one 12-hour night-scan loop; AltAz transforms are computed once per step while all three modes (strict, practical, illumination) are evaluated simultaneouslyReduces Astropy AltAz transforms by ~66% (144 instead of 432)

These optimisations are enforced by the config=None parameter pattern and must not be removed when adding or modifying cache jobs.

Last updated 2026-11-13