Skip to content

Commit c019d63

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
Add datastore caching to entire request object
1 parent 772ed9b commit c019d63

File tree

1 file changed

+180
-26
lines changed

1 file changed

+180
-26
lines changed

internal/server/server.go

Lines changed: 180 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
543659
func (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

Comments
 (0)