Documentation
¶
Overview ¶
Package cache provides query plan caching for NornicDB.
Query plan caching avoids re-parsing identical Cypher queries, significantly improving throughput for repeated queries.
Features: - LRU eviction for bounded memory - TTL expiration for stale plans - Thread-safe operations - Cache hit/miss statistics
Usage:
cache := NewQueryCache(1000, 5*time.Minute)
// Check cache before parsing
if plan, ok := cache.Get(query); ok {
return plan // Cache hit
}
// Parse and cache
plan := parseQuery(query)
cache.Put(query, plan)
Index ¶
- func ConfigureGlobalCache(maxSize int, ttl time.Duration)
- type CacheStats
- type QueryCache
- func (c *QueryCache) Clear()
- func (c *QueryCache) Get(key uint64) (interface{}, bool)
- func (c *QueryCache) Key(query string, params map[string]interface{}) uint64
- func (c *QueryCache) Len() int
- func (c *QueryCache) Put(key uint64, value interface{})
- func (c *QueryCache) Remove(key uint64)
- func (c *QueryCache) SetEnabled(enabled bool)
- func (c *QueryCache) Stats() CacheStats
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func ConfigureGlobalCache ¶
ConfigureGlobalCache configures the global query cache.
This function must be called before the first use of GlobalQueryCache() to customize the cache settings. Subsequent calls are no-ops (first call wins).
Parameters:
- maxSize: Maximum number of cached plans (LRU eviction when exceeded)
- ttl: Time-to-live for cached entries (0 = no expiration)
Example 1 - Application Initialization:
func main() {
// Configure cache early in main()
cache.ConfigureGlobalCache(5000, 10*time.Minute)
// Start application
server.Start()
}
Example 2 - Environment-Based Configuration:
func init() {
maxSize := getEnvInt("CACHE_SIZE", 1000)
ttl := getEnvDuration("CACHE_TTL", 5*time.Minute)
cache.ConfigureGlobalCache(maxSize, ttl)
}
Example 3 - Production vs Development:
func init() {
if os.Getenv("ENV") == "production" {
// Large cache for production
cache.ConfigureGlobalCache(10000, 15*time.Minute)
} else {
// Small cache for development
cache.ConfigureGlobalCache(100, 1*time.Minute)
}
}
Example 4 - Testing:
func TestMain(m *testing.M) {
// Small cache for tests
cache.ConfigureGlobalCache(10, 0)
os.Exit(m.Run())
}
Timing:
- Call in init() or early in main()
- Before any query execution
- Before starting HTTP server
Thread Safety:
- First call wins (sync.Once)
- Subsequent calls are ignored
- Safe to call from multiple goroutines
ELI12:
ConfigureGlobalCache is like setting up the classroom before students arrive:
- You decide how big the shared notebook should be (maxSize)
- You decide how long notes stay valid (ttl)
- Once students arrive, you can't change the notebook (first call wins)
- Do this in init() or main() before anyone uses the cache!
Types ¶
type CacheStats ¶
type CacheStats struct {
Size int // Current number of entries
MaxSize int // Maximum capacity
Hits uint64 // Number of cache hits
Misses uint64 // Number of cache misses
HitRate float64 // Hit rate percentage (0-100)
}
CacheStats holds cache performance statistics.
Use these statistics to monitor cache effectiveness and make tuning decisions. All fields are safe to read concurrently.
Fields:
- Size: Current number of entries in the cache
- MaxSize: Maximum capacity (from NewQueryCache)
- Hits: Total number of successful cache lookups
- Misses: Total number of cache misses (parse required)
- HitRate: Percentage of lookups that were hits (0-100)
Example 1 - Health Check:
func checkCacheHealth() error {
stats := cache.GlobalQueryCache().Stats()
if stats.HitRate < 50 {
return fmt.Errorf("cache hit rate too low: %.1f%%", stats.HitRate)
}
if stats.Size == stats.MaxSize {
log.Warn("Cache is full - consider increasing size")
}
return nil
}
Example 2 - Dashboard Display:
stats := cache.GlobalQueryCache().Stats()
fmt.Printf(`
Query Cache Status:
Capacity: %d/%d (%.1f%% full)
Hit Rate: %.2f%%
Total Requests: %d
Hits: %d
Misses: %d
`,
stats.Size, stats.MaxSize,
float64(stats.Size)/float64(stats.MaxSize)*100,
stats.HitRate,
stats.Hits+stats.Misses,
stats.Hits,
stats.Misses)
Example 3 - Prometheus Metrics:
func exportPrometheusMetrics(stats cache.CacheStats) {
prometheus.GaugeSet("query_cache_size", float64(stats.Size))
prometheus.GaugeSet("query_cache_max_size", float64(stats.MaxSize))
prometheus.GaugeSet("query_cache_hit_rate", stats.HitRate)
prometheus.CounterAdd("query_cache_hits_total", float64(stats.Hits))
prometheus.CounterAdd("query_cache_misses_total", float64(stats.Misses))
}
ELI12:
CacheStats is like a report card for your cache:
- Size/MaxSize: How full is your backpack? (5/10 books)
- Hits: How many times you found your homework in your backpack
- Misses: How many times you had to search your locker
- HitRate: Your success percentage (80% means you find it 8 out of 10 times)
Higher hit rate = better cache = faster queries!
type QueryCache ¶
type QueryCache struct {
// contains filtered or unexported fields
}
QueryCache is a thread-safe LRU cache for parsed query plans.
The cache uses: - Hash map for O(1) lookups - Doubly-linked list for LRU ordering - TTL for automatic expiration
Example:
cache := NewQueryCache(1000, 5*time.Minute)
// Try cache first
key := cache.Key(query, params)
if plan, ok := cache.Get(key); ok {
return plan.(*ParsedPlan)
}
// Parse and cache
plan := parseQuery(query)
cache.Put(key, plan)
func GlobalQueryCache ¶
func GlobalQueryCache() *QueryCache
GlobalQueryCache returns the global query cache instance.
The global cache is a singleton that's lazily initialized with default settings (1000 entries, 5-minute TTL). Use ConfigureGlobalCache to customize the cache before first use.
Returns:
- Shared QueryCache instance
Example 1 - Simple Usage:
func executeQuery(query string, params map[string]interface{}) (*Result, error) {
cache := cache.GlobalQueryCache()
key := cache.Key(query, params)
if plan, ok := cache.Get(key); ok {
return executePlan(plan.(*ParsedPlan), params)
}
plan, err := parseQuery(query)
if err != nil {
return nil, err
}
cache.Put(key, plan)
return executePlan(plan, params)
}
Example 2 - With Custom Configuration:
func init() {
// Configure before first use
cache.ConfigureGlobalCache(5000, 10*time.Minute)
}
func main() {
// Now uses custom configuration
cache := cache.GlobalQueryCache()
fmt.Printf("Cache size: %d\n", cache.Len())
}
Example 3 - Monitoring:
go func() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
stats := cache.GlobalQueryCache().Stats()
log.Printf("Cache hit rate: %.1f%%", stats.HitRate)
}
}()
Default Configuration:
- MaxSize: 1000 entries
- TTL: 5 minutes
- Enabled: true
Thread Safety:
- Singleton initialization is thread-safe
- All cache operations are thread-safe
ELI12:
GlobalQueryCache is like having ONE shared notebook for the whole class:
- Everyone uses the same notebook (singleton)
- First person to open it sets it up (lazy initialization)
- Everyone can read and write at the same time (thread-safe)
- No need to pass the notebook around - just call GlobalQueryCache()!
func NewQueryCache ¶
func NewQueryCache(maxSize int, ttl time.Duration) *QueryCache
NewQueryCache creates a new query cache.
Parameters:
- maxSize: Maximum number of cached plans (LRU eviction when exceeded)
- ttl: Time-to-live for cached entries (0 = no expiration)
Example:
// Cache up to 1000 plans for 5 minutes each cache := NewQueryCache(1000, 5*time.Minute) // Unlimited TTL (only LRU eviction) cache = NewQueryCache(1000, 0)
func (*QueryCache) Clear ¶
func (c *QueryCache) Clear()
Clear removes all entries from the cache.
Use this to completely reset the cache, for example during testing, after major schema changes, or when switching databases.
Example 1 - Testing:
func TestQueryExecution(t *testing.T) {
cache := cache.NewQueryCache(100, 0)
// Test with cache
result1 := executeQuery("MATCH (n) RETURN n", nil)
// Clear for next test
cache.Clear()
// Test without cache
result2 := executeQuery("MATCH (n) RETURN n", nil)
}
Example 2 - Schema Migration:
func migrateSchema() error {
// Perform migration
if err := db.Migrate(); err != nil {
return err
}
// Invalidate all cached plans
cache.GlobalQueryCache().Clear()
return nil
}
Example 3 - Memory Pressure:
// Free memory under pressure
if memoryPressure() {
cache.GlobalQueryCache().Clear()
runtime.GC()
}
Performance:
- O(n) where n is cache size
- Reinitializes internal structures
- Resets statistics
func (*QueryCache) Get ¶
func (c *QueryCache) Get(key uint64) (interface{}, bool)
Get retrieves a cached plan if present and not expired.
This method performs an O(1) lookup in the cache map and automatically:
- Checks TTL expiration (removes expired entries)
- Updates LRU ordering (moves accessed entry to front)
- Tracks hit/miss statistics
Parameters:
- key: Cache key from Key() method
Returns:
- (value, true) on cache hit
- (nil, false) on cache miss or expiration
Example 1 - Basic Cache Check:
cache := cache.NewQueryCache(1000, 5*time.Minute)
key := cache.Key(query, params)
if plan, ok := cache.Get(key); ok {
fmt.Println("Cache hit!")
return plan.(*ParsedPlan)
}
fmt.Println("Cache miss - need to parse")
Example 2 - Query Executor Pattern:
func (e *Executor) Execute(query string, params map[string]interface{}) (*Result, error) {
key := e.cache.Key(query, params)
// Fast path: cached plan
if cached, ok := e.cache.Get(key); ok {
plan := cached.(*ParsedPlan)
return e.executePlan(plan, params)
}
// Slow path: parse and cache
plan, err := e.parser.Parse(query)
if err != nil {
return nil, err
}
e.cache.Put(key, plan)
return e.executePlan(plan, params)
}
Example 3 - TTL Expiration:
cache := cache.NewQueryCache(1000, 1*time.Second)
key := cache.Key("MATCH (n) RETURN n", nil)
cache.Put(key, parsedPlan)
// Immediate access: cache hit
if _, ok := cache.Get(key); ok {
fmt.Println("Hit!") // Prints
}
// After TTL: cache miss (auto-removed)
time.Sleep(2 * time.Second)
if _, ok := cache.Get(key); !ok {
fmt.Println("Expired!") // Prints
}
Example 4 - Type Assertion:
if cached, ok := cache.Get(key); ok {
// Type assert to your plan type
plan, ok := cached.(*ParsedPlan)
if !ok {
return nil, fmt.Errorf("invalid cached type")
}
return executePlan(plan, params)
}
Performance:
- Cache hit: O(1) map lookup + O(1) list move
- Cache miss: O(1) map lookup
- TTL check: O(1) time comparison
- Typical latency: <100 ns
Thread Safety:
- Safe for concurrent reads (RLock)
- Safe for concurrent writes (Lock)
- Statistics updated atomically
ELI12:
Imagine a library with a "recently returned" shelf:
- Get checks if your book is on the shelf
- If found, you take it and move it to the front (most recent)
- If the book is too old (expired), it's thrown away
- If not found, you have to go find it in the main stacks (parse)
The cache remembers what you looked at recently so you don't have to search the whole library every time!
func (*QueryCache) Key ¶
func (c *QueryCache) Key(query string, params map[string]interface{}) uint64
Key generates a cache key from query and parameters.
The key is a 64-bit hash (FNV-1a algorithm) that uniquely identifies a query pattern. The hash includes the query text and parameter keys (but not values), allowing parameterized queries to be cached efficiently.
Parameters:
- query: The Cypher query string
- params: Query parameters (only keys are hashed, not values)
Returns:
- uint64 hash suitable for map lookups
Example 1 - Basic Usage:
cache := cache.NewQueryCache(1000, 5*time.Minute)
query := "MATCH (n:Person {name: $name}) RETURN n"
params := map[string]interface{}{"name": "Alice"}
key := cache.Key(query, params)
fmt.Printf("Cache key: %d\n", key)
Example 2 - Same Query, Different Values:
// These produce the SAME key (parameter values don't matter)
key1 := cache.Key("MATCH (n {id: $id}) RETURN n", map[string]interface{}{"id": 1})
key2 := cache.Key("MATCH (n {id: $id}) RETURN n", map[string]interface{}{"id": 2})
// key1 == key2 (same query pattern)
// This produces a DIFFERENT key (different query)
key3 := cache.Key("MATCH (n {name: $name}) RETURN n", map[string]interface{}{"name": "Bob"})
// key3 != key1 (different query pattern)
Example 3 - Integration with Parser:
func executeQuery(query string, params map[string]interface{}) (*Result, error) {
cache := cache.GlobalQueryCache()
key := cache.Key(query, params)
// Try cache first
if plan, ok := cache.Get(key); ok {
return executePlan(plan.(*ParsedPlan), params)
}
// Parse and cache
plan, err := parseQuery(query)
if err != nil {
return nil, err
}
cache.Put(key, plan)
return executePlan(plan, params)
}
Performance:
- FNV-1a hash: ~50-100 ns for typical queries
- O(1) lookup in cache map
- Parameter keys included for correctness
- Parameter values excluded for reusability
ELI12:
Think of the cache key like a fingerprint for a query:
- Same query pattern = same fingerprint
- Different values (like "Alice" vs "Bob") = same fingerprint
- Different query = different fingerprint
Why? Because the query structure is what we cache, not the specific values. It's like caching a recipe (the steps) rather than the actual meal (with specific ingredients). You can use the same recipe with different ingredients!
func (*QueryCache) Len ¶
func (c *QueryCache) Len() int
Len returns the number of cached entries.
Use this to monitor cache utilization or for debugging.
Returns:
- Current number of entries in the cache
Example 1 - Monitoring:
cache := cache.GlobalQueryCache()
fmt.Printf("Cache size: %d/%d\n", cache.Len(), 1000)
Example 2 - Metrics:
func collectMetrics() {
cache := cache.GlobalQueryCache()
stats := cache.Stats()
metrics.Gauge("cache.size", float64(cache.Len()))
metrics.Gauge("cache.hit_rate", stats.HitRate)
}
Performance:
- O(1) with read lock
func (*QueryCache) Put ¶
func (c *QueryCache) Put(key uint64, value interface{})
Put adds a plan to the cache.
This method stores a parsed query plan in the cache for future reuse. It automatically handles:
- LRU eviction when cache is full
- TTL timestamp setting
- Updating existing entries
- Moving entry to front of LRU list
Parameters:
- key: Cache key from Key() method
- value: Parsed query plan (typically *ParsedPlan)
Example 1 - Basic Caching:
cache := cache.NewQueryCache(1000, 5*time.Minute)
query := "MATCH (n:Person) RETURN n"
plan := parseQuery(query) // Your parser
key := cache.Key(query, nil)
cache.Put(key, plan)
// Later: instant retrieval
if cached, ok := cache.Get(key); ok {
fmt.Println("Reusing cached plan!")
}
Example 2 - Parse-Once Pattern:
func getOrParsePlan(query string, params map[string]interface{}) (*ParsedPlan, error) {
cache := cache.GlobalQueryCache()
key := cache.Key(query, params)
// Try cache
if cached, ok := cache.Get(key); ok {
return cached.(*ParsedPlan), nil
}
// Parse (expensive operation)
plan, err := parser.Parse(query)
if err != nil {
return nil, err
}
// Cache for next time
cache.Put(key, plan)
return plan, nil
}
Example 3 - Updating Cached Entry:
// First put key := cache.Key(query, nil) cache.Put(key, plan1) // Later: update with optimized plan optimizedPlan := optimizePlan(plan1) cache.Put(key, optimizedPlan) // Replaces old value
Example 4 - LRU Eviction:
cache := cache.NewQueryCache(3, 0) // Only 3 entries, no TTL cache.Put(1, "plan-A") cache.Put(2, "plan-B") cache.Put(3, "plan-C") // Cache: [C, B, A] (most recent first) cache.Get(1) // Access A // Cache: [A, C, B] cache.Put(4, "plan-D") // Cache full, evicts B (least recent) // Cache: [D, A, C]
Performance:
- O(1) insertion or update
- O(1) eviction when full
- No allocations for updates
- Typical latency: <200 ns
Memory Management:
- LRU eviction prevents unbounded growth
- TTL expiration removes stale entries
- Eviction happens synchronously on Put
Thread Safety:
- Exclusive lock held during Put
- Safe for concurrent Put/Get operations
ELI12:
Think of Put like adding a book to the "recently returned" shelf:
- If there's space, just add it to the front
- If the shelf is full, remove the oldest book from the back
- If the book is already there, move it to the front with new info
- Mark when it was added so we know when it's too old
The shelf always keeps the most recently used books, automatically throwing away old ones you haven't touched in a while!
func (*QueryCache) Remove ¶
func (c *QueryCache) Remove(key uint64)
Remove removes an entry from the cache.
Use this to manually invalidate a cached query plan, for example when the underlying data schema changes or when you know a plan is no longer valid.
Parameters:
- key: Cache key to remove
Example 1 - Schema Change Invalidation:
func createIndex(label, property string) error {
if err := db.CreateIndex(label, property); err != nil {
return err
}
// Invalidate affected queries
cache := cache.GlobalQueryCache()
for _, query := range affectedQueries {
key := cache.Key(query, nil)
cache.Remove(key)
}
return nil
}
Example 2 - Selective Invalidation:
// Remove specific query from cache query := "MATCH (n:Person) RETURN n" key := cache.Key(query, nil) cache.Remove(key) // Next execution will re-parse result := executeQuery(query, nil) // Cache miss
Performance:
- O(1) removal from map and list
- No-op if key doesn't exist
func (*QueryCache) SetEnabled ¶
func (c *QueryCache) SetEnabled(enabled bool)
SetEnabled enables or disables the cache.
When disabled, all Get operations return cache misses and Put operations are no-ops. The cache is also cleared when disabled. Use this for debugging or when you want to bypass caching temporarily.
Parameters:
- enabled: true to enable caching, false to disable
Example 1 - Debugging:
// Disable cache to test parsing performance
cache := cache.GlobalQueryCache()
cache.SetEnabled(false)
start := time.Now()
for i := 0; i < 1000; i++ {
executeQuery("MATCH (n) RETURN n", nil)
}
fmt.Printf("Without cache: %v\n", time.Since(start))
// Re-enable for comparison
cache.SetEnabled(true)
start = time.Now()
for i := 0; i < 1000; i++ {
executeQuery("MATCH (n) RETURN n", nil)
}
fmt.Printf("With cache: %v\n", time.Since(start))
Example 2 - Conditional Caching:
func executeQuery(query string, useCache bool) (*Result, error) {
cache := cache.GlobalQueryCache()
cache.SetEnabled(useCache)
// Execute query (cache behavior depends on useCache)
return executor.Execute(query, nil)
}
Example 3 - Testing:
func TestParserWithoutCache(t *testing.T) {
cache := cache.NewQueryCache(100, 0)
cache.SetEnabled(false) // Force re-parsing
// All queries will be parsed fresh
for _, query := range testQueries {
result := executeQuery(query, nil)
// Verify parsing logic...
}
}
Performance Impact:
- Disabled: All Get() returns false (cache miss)
- Disabled: All Put() are no-ops
- Disabling clears the cache (frees memory)
Thread Safety:
- Safe to call concurrently
- Exclusive lock held during state change
func (*QueryCache) Stats ¶
func (c *QueryCache) Stats() CacheStats
Stats returns cache statistics.
Use this to monitor cache performance and tune cache size and TTL settings. Statistics are tracked atomically and have minimal performance overhead.
Returns:
- CacheStats with hit rate, size, and access counts
Example 1 - Performance Monitoring:
cache := cache.GlobalQueryCache()
stats := cache.Stats()
fmt.Printf("Cache Performance:\n")
fmt.Printf(" Size: %d/%d (%.1f%% full)\n",
stats.Size, stats.MaxSize,
float64(stats.Size)/float64(stats.MaxSize)*100)
fmt.Printf(" Hit Rate: %.2f%%\n", stats.HitRate)
fmt.Printf(" Hits: %d\n", stats.Hits)
fmt.Printf(" Misses: %d\n", stats.Misses)
Example 2 - Metrics Collection:
func recordCacheMetrics() {
cache := cache.GlobalQueryCache()
stats := cache.Stats()
metrics.Gauge("query_cache.size", float64(stats.Size))
metrics.Gauge("query_cache.hit_rate", stats.HitRate)
metrics.Counter("query_cache.hits", float64(stats.Hits))
metrics.Counter("query_cache.misses", float64(stats.Misses))
}
Example 3 - Tuning Decisions:
stats := cache.GlobalQueryCache().Stats()
if stats.HitRate < 50 {
log.Println("Low hit rate - consider increasing cache size")
}
if stats.Size == stats.MaxSize {
log.Println("Cache full - consider increasing maxSize")
}
Example 4 - Periodic Reporting:
go func() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
stats := cache.GlobalQueryCache().Stats()
log.Printf("Cache: %d entries, %.1f%% hit rate",
stats.Size, stats.HitRate)
}
}()
Interpreting Hit Rate:
- >80%: Excellent - cache is very effective
- 60-80%: Good - cache is helping
- 40-60%: Fair - consider tuning
- <40%: Poor - cache may be too small or TTL too short
Performance:
- O(1) with read lock
- Atomic statistics access
- No allocations
ELI12:
Stats tells you how well your cache is working:
- Hit Rate: How often you find what you're looking for (higher is better)
- Size: How many things are in the cache right now
- Hits: How many times you found what you wanted
- Misses: How many times you had to go searching
It's like checking your homework success rate - if you're getting most answers from your notes (high hit rate), your notes are working well!