11# src/where_the_plow/cache.py
2- """Simple file-based cache for coverage trail responses.
2+ """Disk cache for coverage trail responses.
33
4- Stores JSON in /tmp/where-the-plow-cache/ keyed by a hash of the
5- (since, until) time range. Only caches queries whose `until` is
6- before today (i.e. fully historical, immutable data). Uses LRU
7- eviction by file access time when total cache size exceeds a budget.
4+ Stores JSON in /tmp/where-the-plow-cache/ keyed by a hash of
5+ (since, until, source). Each entry carries an absolute expiry time.
6+ Uses LRU-style eviction by file access time when total cache size
7+ exceeds a budget.
88"""
99
1010import hashlib
1111import json
1212import logging
1313import os
1414import tempfile
15+ import time
1516from datetime import datetime , timezone
1617from pathlib import Path
1718
2021CACHE_DIR = Path (tempfile .gettempdir ()) / "where-the-plow-cache"
2122MAX_CACHE_BYTES = 200 * 1024 * 1024 # 200 MB
2223
24+ # Cache policy tuned for coverage endpoint
25+ RECENT_TTL_SECONDS = 5 * 60 # 5 minutes for live-ish windows
26+ HISTORICAL_TTL_SECONDS = 24 * 60 * 60 # 1 day for immutable historical windows
2327
24- def _cache_key (since : datetime , until : datetime ) -> str :
25- raw = f"{ since .isoformat ()} |{ until .isoformat ()} "
26- return hashlib .sha256 (raw .encode ()).hexdigest ()[:16 ]
2728
29+ def _cache_key (since : datetime , until : datetime , source : str | None ) -> str :
30+ raw = f"{ since .isoformat ()} |{ until .isoformat ()} |{ source or '' } "
31+ return hashlib .sha256 (raw .encode ()).hexdigest ()[:24 ]
2832
29- def _is_cacheable (until : datetime ) -> bool :
30- """Only cache if the entire window is in the past (before today UTC)."""
33+
34+ def _is_historical (until : datetime ) -> bool :
35+ """Return True if window is fully in the past (before today UTC)."""
3136 today_start = datetime .now (timezone .utc ).replace (
3237 hour = 0 , minute = 0 , second = 0 , microsecond = 0
3338 )
3439 until_utc = until if until .tzinfo else until .replace (tzinfo = timezone .utc )
3540 return until_utc < today_start
3641
3742
43+ def _ttl_for (until : datetime ) -> int :
44+ return HISTORICAL_TTL_SECONDS if _is_historical (until ) else RECENT_TTL_SECONDS
45+
46+
3847def _ensure_dir ():
3948 CACHE_DIR .mkdir (parents = True , exist_ok = True )
4049
@@ -48,7 +57,6 @@ def _evict_if_needed():
4857 total = sum (f .stat ().st_size for f in files )
4958 if total <= MAX_CACHE_BYTES :
5059 return
51- # Sort by access time, oldest first
5260 files .sort (key = lambda f : f .stat ().st_atime )
5361 for f in files :
5462 if total <= MAX_CACHE_BYTES :
@@ -61,32 +69,48 @@ def _evict_if_needed():
6169 pass
6270
6371
64- def get (since : datetime , until : datetime ) -> list [dict ] | None :
65- """Return cached trails or None if not cached."""
66- if not _is_cacheable (until ):
67- return None
68- path = CACHE_DIR / f"{ _cache_key (since , until )} .json"
72+ def _delete_if_expired (path : Path , expires_at : float ) -> bool :
73+ if time .time () <= expires_at :
74+ return False
75+ try :
76+ path .unlink (missing_ok = True )
77+ except OSError :
78+ pass
79+ return True
80+
81+
82+ def get (since : datetime , until : datetime , source : str | None = None ) -> list [dict ] | None :
83+ """Return cached trails or None."""
84+ path = CACHE_DIR / f"{ _cache_key (since , until , source )} .json"
6985 if not path .exists ():
7086 return None
7187 try :
72- # Touch access time for LRU
88+ payload = json .loads (path .read_text ())
89+ expires_at = float (payload .get ("expires_at" , 0 ))
90+ trails = payload .get ("trails" )
91+ if not isinstance (trails , list ):
92+ return None
93+ if _delete_if_expired (path , expires_at ):
94+ return None
7395 os .utime (path )
74- data = json .loads (path .read_text ())
7596 logger .debug ("cache hit: %s" , path .name )
76- return data
77- except (OSError , json .JSONDecodeError ):
97+ return trails
98+ except (OSError , ValueError , json .JSONDecodeError ):
7899 return None
79100
80101
81- def put (since : datetime , until : datetime , trails : list [dict ]):
82- """Store trails in cache if the query is cacheable."""
83- if not _is_cacheable (until ):
84- return
102+ def put (since : datetime , until : datetime , trails : list [dict ], source : str | None = None ):
103+ """Store trails in disk cache with endpoint-specific TTL policy."""
85104 _ensure_dir ()
86105 _evict_if_needed ()
87- path = CACHE_DIR / f"{ _cache_key (since , until )} .json"
106+ path = CACHE_DIR / f"{ _cache_key (since , until , source )} .json"
107+ ttl = _ttl_for (until )
108+ payload = {
109+ "expires_at" : time .time () + ttl ,
110+ "trails" : trails ,
111+ }
88112 try :
89- path .write_text (json .dumps (trails ))
90- logger .debug ("cache put: %s (%d trails)" , path .name , len (trails ))
113+ path .write_text (json .dumps (payload ))
114+ logger .debug ("cache put: %s (%d trails, ttl=%ds )" , path .name , len (trails ), ttl )
91115 except OSError :
92116 pass
0 commit comments