Skip to content

Instantly share code, notes, and snippets.

@caseylmanus
Last active April 23, 2025 10:51
Show Gist options
  • Save caseylmanus/5b1e6e6d874c95437ec1a9a44dd2c78f to your computer and use it in GitHub Desktop.
Save caseylmanus/5b1e6e6d874c95437ec1a9a44dd2c78f to your computer and use it in GitHub Desktop.
pullthroughcache.go
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/patrickmn/go-cache"
)
// Define the function signature for the underlying function we want to cache.
// The key is removed, as per the user request. Added context.Context.
type DataFetcher func(ctx context.Context, app, principal string) (map[string]struct{}, error)
// cacheKey generates a unique cache key based on the app and principal.
// The key is NOT included here.
func cacheKey(app, principal string) string {
return fmt.Sprintf("%s:%s", app, principal)
}
// NewPullThroughCache creates a new pull-through cache using go-cache.
func NewPullThroughCache(fetcher DataFetcher) *PullThroughCache {
// Create a new go-cache with a default expiration time of 5 minutes and
// a purge interval of 10 minutes. The purge interval doesn't drastically
// affect the sliding expiration, but it's good practice to set it.
c := cache.New(5*time.Minute, 10*time.Minute)
return &PullThroughCache{
cache: c,
fetcher: fetcher,
}
}
// PullThroughCache is a struct that wraps the go-cache and the data fetching function.
type PullThroughCache struct {
cache *cache.Cache
fetcher DataFetcher
}
// GetOrLoad retrieves data from the cache, or loads it using the provided
// fetcher function if it's not in the cache.
func (p *PullThroughCache) GetOrLoad(ctx context.Context, app, principal, key string) (map[string]struct{}, error) {
cacheKey := cacheKey(app, principal) // key no longer part of cache key.
// Check if the value is in the cache.
cachedValue, found := p.cache.Get(cacheKey)
if found {
// Type assert the cached value to the correct type. This is crucial.
if value, ok := cachedValue.(map[string]struct{}); ok {
return value, nil // Return the cached value.
} else {
// Should not happen, unless there's a bug in the cache or an external modification.
log.Printf("error: unexpected type in cache for key %s: got %T, expected %T", cacheKey, cachedValue, map[string]struct{}{})
p.cache.Delete(cacheKey) //remove the invalid entry
}
}
// If the value is not in the cache, fetch it using the provided fetcher function.
// The key is no longer passed to the fetcher. Pass the context.
value, err := p.fetcher(ctx, app, principal)
if err != nil {
return nil, err // Return the error from the fetcher function.
}
// Only cache the result if the key is present in the returned map.
_, keyPresent := value[key]
if keyPresent {
// Set the value in the cache with a 5-minute expiration time.
p.cache.Set(cacheKey, value, cache.DefaultExpiration)
} else {
// Invalidate the entire cache for this app and principal
p.invalidateCache(app, principal)
}
return value, nil // Return the fetched value.
}
// invalidateCache invalidates all cache entries for a given app and principal.
// This is necessary when the underlying data changes. The key is not part of the cache key.
func (p *PullThroughCache) invalidateCache(app, principal string) {
p.cache.Delete(cacheKey(app, principal)) // Delete exact key
}
func main() {
// Simulate a data fetching function.
// The key parameter is removed from the function signature.
// Added context parameter.
fetchData := func(ctx context.Context, app, principal string) (map[string]struct{}, error) {
fmt.Printf("Fetching data for app: %s, principal: %s from source with context: %v...\n", app, principal, ctx)
// Simulate a database or API call with a delay.
time.Sleep(500 * time.Millisecond) // Simulate latency
// Simulate different results based on the key.
// The key logic remains here, to simulate different data sets
// being returned.
if app == "myApp" && principal == "user123" {
return map[string]struct{}{"key1": {}, "key2": {}}, nil
} else if app == "myApp" && principal == "user456" {
return map[string]struct{}{"keyA": {}, "keyB": {}}, nil
}
return map[string]struct{}{}, nil
}
// Create a new pull-through cache.
cache := NewPullThroughCache(fetchData)
// --- Example Usage ---
app := "myApp"
principal := "user123"
ctx := context.Background() // Use a background context.
// First call for key1: data will be fetched and cached.
result1, err := cache.GetOrLoad(ctx, app, principal, "key1")
if err != nil {
log.Fatalf("Error getting data for key1: %v", err)
}
fmt.Printf("Result 1: %v\n", result1)
// Second call for key1: data will be retrieved from the cache.
result2, err := cache.GetOrLoad(ctx, app, principal, "key2")
if err != nil {
log.Fatalf("Error getting data for key2: %v", err)
}
fmt.Printf("Result 2: %v (from cache)\n", result2)
// Call for key3: data will be fetched, and because key3 is not in the
// result, the cache will be invalidated.
result3, err := cache.GetOrLoad(ctx, app, principal, "key3")
if err != nil {
log.Fatalf("Error getting data for key3: %v", err)
}
fmt.Printf("Result 3: %v\n", result3)
// Call for key1 again: data will be fetched from source because the cache
// was invalidated in the previous step.
result4, err := cache.GetOrLoad(ctx, app, principal, "key1")
if err != nil {
log.Fatalf("Error getting data for key1: %v", err)
}
fmt.Printf("Result 4: %v (after invalidation)\n", result4)
// Wait for longer than the TTL.
time.Sleep(6 * time.Minute)
// Call for key1 again: Data will be fetched again after TTL expiry
result5, err := cache.GetOrLoad(ctx, app, principal, "key1")
if err != nil {
log.Fatalf("Error getting data for key1: %v", err)
}
fmt.Printf("Result 5: %v (after TTL expiry)\n", result5)
// Example with a different principal to show cache isolation.
principal2 := "user456"
result6, err := cache.GetOrLoad(ctx, app, principal2, "keyA") // Should not be affected by previous invalidation
if err != nil {
log.Fatalf("Error getting data for keyA: %v", err)
}
fmt.Printf("Result 6 (different principal): %v\n", result6)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment