@@ -78,6 +78,15 @@ type prQueryCacheEntity struct {
7878 QueryKey string `datastore:"query_key"` // Full query key for debugging
7979}
8080
81+ // calcResultCacheEntity represents a cached calculation result in DataStore with TTL.
82+ type calcResultCacheEntity struct {
83+ Data string `datastore:"data,noindex"` // JSON-encoded cost.Breakdown
84+ CachedAt time.Time `datastore:"cached_at"` // When this was cached
85+ ExpiresAt time.Time `datastore:"expires_at"` // When this expires
86+ URL string `datastore:"url"` // PR URL for debugging
87+ ConfigKey string `datastore:"config_key"` // Config hash for debugging
88+ }
89+
8190// Server handles HTTP requests for the PR Cost API.
8291//
8392//nolint:govet // fieldalignment: struct field ordering optimized for readability over memory
@@ -101,10 +110,12 @@ type Server struct {
101110 validateTokens bool
102111 r2rCallout bool
103112 // In-memory caching for PR queries and data.
104- prQueryCache map [string ]* cacheEntry
105- prDataCache map [string ]* cacheEntry
106- prQueryCacheMu sync.RWMutex
107- prDataCacheMu sync.RWMutex
113+ prQueryCache map [string ]* cacheEntry
114+ prDataCache map [string ]* cacheEntry
115+ calcResultCache map [string ]* cacheEntry
116+ prQueryCacheMu sync.RWMutex
117+ prDataCacheMu sync.RWMutex
118+ calcResultCacheMu sync.RWMutex
108119 // DataStore client for persistent caching (nil if not enabled).
109120 dsClient * ds9.Client
110121}
@@ -195,16 +206,17 @@ func New() *Server {
195206 logger .InfoContext (ctx , "Server initialized with CSRF protection enabled" )
196207
197208 server := & Server {
198- logger : logger ,
199- serverCommit : "" , // Will be set via build flags
200- dataSource : "turnserver" ,
201- httpClient : httpClient ,
202- csrfProtection : csrfProtection ,
203- ipLimiters : make (map [string ]* rate.Limiter ),
204- rateLimit : DefaultRateLimit ,
205- rateBurst : DefaultRateBurst ,
206- prQueryCache : make (map [string ]* cacheEntry ),
207- prDataCache : make (map [string ]* cacheEntry ),
209+ logger : logger ,
210+ serverCommit : "" , // Will be set via build flags
211+ dataSource : "turnserver" ,
212+ httpClient : httpClient ,
213+ csrfProtection : csrfProtection ,
214+ ipLimiters : make (map [string ]* rate.Limiter ),
215+ rateLimit : DefaultRateLimit ,
216+ rateBurst : DefaultRateBurst ,
217+ prQueryCache : make (map [string ]* cacheEntry ),
218+ prDataCache : make (map [string ]* cacheEntry ),
219+ calcResultCache : make (map [string ]* cacheEntry ),
208220 }
209221
210222 // Load GitHub token at startup and cache in memory for performance and billing.
@@ -421,14 +433,14 @@ func (s *Server) cachePRQuery(ctx context.Context, key string, prs []github.PRSu
421433 switch {
422434 case strings .HasPrefix (key , "repo:" ):
423435 queryType = "repo"
424- ttl = 72 * time .Hour // 72 hours for repo queries
436+ ttl = 60 * time .Hour // 60 hours for repo queries
425437 case strings .HasPrefix (key , "org:" ):
426438 queryType = "org"
427- ttl = 72 * time .Hour // 72 hours for org queries
439+ ttl = 60 * time .Hour // 60 hours for org queries
428440 default :
429441 s .logger .WarnContext (ctx , "Unknown query type for key, using default TTL" , "key" , key )
430442 queryType = "unknown"
431- ttl = 72 * time .Hour // Default to 72 hours
443+ ttl = 60 * time .Hour // Default to 60 hours
432444 }
433445
434446 now := time .Now ()
@@ -539,6 +551,110 @@ func (s *Server) cachePRData(ctx context.Context, key string, prData cost.PRData
539551 s .logger .DebugContext (ctx , "PR data cached to DataStore" , "key" , key , "expires_at" , entity .ExpiresAt )
540552}
541553
554+ // configHash creates a deterministic hash key for a cost.Config.
555+ // Returns a short hash string suitable for use in cache keys.
556+ func configHash (cfg cost.Config ) string {
557+ // Create a deterministic string representation of the config
558+ // Use %.2f for floats to avoid floating point precision issues
559+ return fmt .Sprintf ("s%.0f_e%.0f_ci%.0f_co%.0f_g%.0f_d%.2f" ,
560+ cfg .AnnualSalary ,
561+ cfg .EventDuration .Minutes (),
562+ cfg .ContextSwitchInDuration .Minutes (),
563+ cfg .ContextSwitchOutDuration .Minutes (),
564+ cfg .SessionGapThreshold .Minutes (),
565+ cfg .DeliveryDelayFactor )
566+ }
567+
568+ // cachedCalcResult retrieves cached calculation result from memory first, then DataStore as fallback.
569+ func (s * Server ) cachedCalcResult (ctx context.Context , prURL string , cfg cost.Config ) (cost.Breakdown , bool ) {
570+ key := fmt .Sprintf ("calc:%s:%s" , prURL , configHash (cfg ))
571+
572+ // Check in-memory cache first (fast path).
573+ s .calcResultCacheMu .RLock ()
574+ entry , exists := s .calcResultCache [key ]
575+ s .calcResultCacheMu .RUnlock ()
576+
577+ if exists {
578+ breakdown , ok := entry .data .(cost.Breakdown )
579+ if ok {
580+ return breakdown , true
581+ }
582+ }
583+
584+ // Memory miss - try DataStore if available.
585+ if s .dsClient == nil {
586+ return cost.Breakdown {}, false
587+ }
588+
589+ dsKey := ds9 .NameKey ("CalcResultCache" , key , nil )
590+ var entity calcResultCacheEntity
591+ err := s .dsClient .Get (ctx , dsKey , & entity )
592+ if err != nil {
593+ if ! errors .Is (err , ds9 .ErrNoSuchEntity ) {
594+ s .logger .WarnContext (ctx , "DataStore calc cache read failed" , "key" , key , "error" , err )
595+ }
596+ return cost.Breakdown {}, false
597+ }
598+
599+ // Check if expired.
600+ if time .Now ().After (entity .ExpiresAt ) {
601+ return cost.Breakdown {}, false
602+ }
603+
604+ // Deserialize the cached data.
605+ var breakdown cost.Breakdown
606+ if err := json .Unmarshal ([]byte (entity .Data ), & breakdown ); err != nil {
607+ s .logger .WarnContext (ctx , "Failed to deserialize cached calc result" , "key" , key , "error" , err )
608+ return cost.Breakdown {}, false
609+ }
610+
611+ // Populate in-memory cache for faster subsequent access.
612+ s .calcResultCacheMu .Lock ()
613+ s .calcResultCache [key ] = & cacheEntry {data : breakdown }
614+ s .calcResultCacheMu .Unlock ()
615+
616+ return breakdown , true
617+ }
618+
619+ // cacheCalcResult stores calculation result in both memory and DataStore caches.
620+ func (s * Server ) cacheCalcResult (ctx context.Context , prURL string , cfg cost.Config , b * cost.Breakdown , ttl time.Duration ) {
621+ key := fmt .Sprintf ("calc:%s:%s" , prURL , configHash (cfg ))
622+
623+ // Write to in-memory cache first (fast path).
624+ s .calcResultCacheMu .Lock ()
625+ s .calcResultCache [key ] = & cacheEntry {data : * b }
626+ s .calcResultCacheMu .Unlock ()
627+
628+ // Write to DataStore if available (persistent cache).
629+ if s .dsClient == nil {
630+ return
631+ }
632+
633+ // Serialize the calculation result.
634+ dataJSON , err := json .Marshal (b )
635+ if err != nil {
636+ s .logger .WarnContext (ctx , "Failed to serialize calc result for DataStore" , "key" , key , "error" , err )
637+ return
638+ }
639+
640+ now := time .Now ()
641+ entity := calcResultCacheEntity {
642+ Data : string (dataJSON ),
643+ CachedAt : now ,
644+ ExpiresAt : now .Add (ttl ),
645+ URL : prURL ,
646+ ConfigKey : configHash (cfg ),
647+ }
648+
649+ dsKey := ds9 .NameKey ("CalcResultCache" , key , nil )
650+ if _ , err := s .dsClient .Put (ctx , dsKey , & entity ); err != nil {
651+ s .logger .WarnContext (ctx , "Failed to write calc result to DataStore" , "key" , key , "error" , err )
652+ return
653+ }
654+
655+ s .logger .DebugContext (ctx , "Calc result cached to DataStore" , "key" , key , "ttl" , ttl , "expires_at" , entity .ExpiresAt )
656+ }
657+
542658// SetTokenValidation configures GitHub token validation.
543659func (s * Server ) SetTokenValidation (appID string , keyFile string ) error {
544660 keyData , err := os .ReadFile (keyFile )
@@ -917,12 +1033,20 @@ func (s *Server) processRequest(ctx context.Context, req *CalculateRequest, toke
9171033 cfg = s .mergeConfig (cfg , req .Config )
9181034 }
9191035
920- // Try cache first
1036+ // Try calculation result cache first (includes both PR data + calculation)
1037+ breakdown , calcCached := s .cachedCalcResult (ctx , req .URL , cfg )
1038+ if calcCached {
1039+ return & CalculateResponse {
1040+ Breakdown : breakdown ,
1041+ Timestamp : time .Now (),
1042+ Commit : s .serverCommit ,
1043+ }, nil
1044+ }
1045+
1046+ // Cache miss - need to fetch PR data and calculate
9211047 cacheKey := fmt .Sprintf ("pr:%s" , req .URL )
922- prData , cached := s .cachedPRData (ctx , cacheKey )
923- if cached {
924- s .logger .InfoContext (ctx , "[processRequest] Using cached PR data" , "url" , req .URL )
925- } else {
1048+ prData , prCached := s .cachedPRData (ctx , cacheKey )
1049+ if ! prCached {
9261050 // Fetch PR data using configured data source
9271051 var err error
9281052 // For single PR requests, use 1 hour ago as reference time to enable reasonable caching
@@ -944,12 +1068,16 @@ func (s *Server) processRequest(ctx context.Context, req *CalculateRequest, toke
9441068 return nil , fmt .Errorf ("failed to fetch PR data: %w" , err )
9451069 }
9461070
947- // Cache PR data
1071+ s .logger .InfoContext (ctx , "[processRequest] PR data cache miss - fetched from GitHub" , "url" , req .URL )
1072+ // Cache PR data with 1 hour TTL for direct PR requests
9481073 s .cachePRData (ctx , cacheKey , prData )
9491074 }
9501075
9511076 // Calculate costs.
952- breakdown := cost .Calculate (prData , cfg )
1077+ breakdown = cost .Calculate (prData , cfg )
1078+
1079+ // Cache the calculation result with 1 hour TTL for direct PR requests
1080+ s .cacheCalcResult (ctx , req .URL , cfg , & breakdown , 1 * time .Hour )
9531081
9541082 return & CalculateResponse {
9551083 Breakdown : breakdown ,
@@ -2198,7 +2326,28 @@ func (s *Server) processPRsInParallel(workCtx, reqCtx context.Context, samples [
21982326
21992327 prURL := fmt .Sprintf ("https://github.com/%s/%s/pull/%d" , owner , repo , prSummary .Number )
22002328
2201- // Try cache first
2329+ // Try calculation result cache first (includes both PR data + calculation)
2330+ breakdown , calcCached := s .cachedCalcResult (workCtx , prURL , cfg )
2331+ if calcCached {
2332+ // Already have the full calculation result
2333+ mu .Lock ()
2334+ breakdowns = append (breakdowns , breakdown )
2335+ mu .Unlock ()
2336+
2337+ // Send "complete" update using request context for SSE
2338+ sseMu .Lock ()
2339+ logSSEError (reqCtx , s .logger , sendSSE (writer , ProgressUpdate {
2340+ Type : "complete" ,
2341+ PR : prSummary .Number ,
2342+ Owner : owner ,
2343+ Repo : repo ,
2344+ Progress : progress ,
2345+ }))
2346+ sseMu .Unlock ()
2347+ return
2348+ }
2349+
2350+ // Cache miss - need to fetch PR data and calculate
22022351 prCacheKey := fmt .Sprintf ("pr:%s" , prURL )
22032352 prData , prCached := s .cachedPRData (workCtx , prCacheKey )
22042353 if ! prCached {
@@ -2225,6 +2374,8 @@ func (s *Server) processPRsInParallel(workCtx, reqCtx context.Context, samples [
22252374 return
22262375 }
22272376
2377+ s .logger .InfoContext (reqCtx , "PR data cache miss - fetched from GitHub" ,
2378+ "pr_number" , prSummary .Number , "owner" , owner , "repo" , repo )
22282379 // Cache the PR data
22292380 s .cachePRData (workCtx , prCacheKey , prData )
22302381 }
@@ -2240,7 +2391,10 @@ func (s *Server) processPRsInParallel(workCtx, reqCtx context.Context, samples [
22402391 }))
22412392 sseMu .Unlock ()
22422393
2243- breakdown := cost .Calculate (prData , cfg )
2394+ breakdown = cost .Calculate (prData , cfg )
2395+
2396+ // Cache the calculation result with 1 week TTL for PRs from queries
2397+ s .cacheCalcResult (workCtx , prURL , cfg , & breakdown , 7 * 24 * time .Hour )
22442398
22452399 // Add to results
22462400 mu .Lock ()
0 commit comments