From 4c54903bd02f9f6e7bd91f98b503b97a55c45d21 Mon Sep 17 00:00:00 2001 From: Shuji Aoshima <47586723+aoshimash@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:08:41 +0900 Subject: [PATCH] feat: Implement JavaScript rendering cache (Phase 3) - Add RenderCache struct with thread-safe LRU eviction - Support configurable TTL and max size for cache entries - Integrate cache with JSClient.Get method - Add FromCache flag to JSResponse - Add CLI flags: --js-cache, --js-cache-size, --js-cache-ttl - Implement comprehensive cache tests including concurrency - Cache hit/miss logging for debugging This significantly improves performance when crawling sites with repeated page requests by avoiding redundant browser renders. Resolves #72 --- .claude/settings.local.json | 6 +- cmd/urlmap/main.go | 19 +- internal/client/js_client.go | 75 +++++- internal/client/js_client_test.go | 159 ++++++++++++ internal/client/js_config.go | 44 +++- internal/client/render_cache.go | 180 ++++++++++++++ internal/client/render_cache_test.go | 355 +++++++++++++++++++++++++++ urlmap | Bin 11704290 -> 11854738 bytes 8 files changed, 811 insertions(+), 27 deletions(-) create mode 100644 internal/client/render_cache.go create mode 100644 internal/client/render_cache_test.go diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3575f6d..b9cfe58 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,11 @@ "Bash(grep:*)", "Bash(git commit:*)", "Bash(git push:*)", - "Bash(go:*)" + "Bash(go:*)", + "Bash(gh issue list:*)", + "Bash(gh issue view:*)", + "Bash(gh auth:*)", + "Bash(gh repo view:*)" ], "deny": [] } diff --git a/cmd/urlmap/main.go b/cmd/urlmap/main.go index 5d2f1d5..fe938d4 100644 --- a/cmd/urlmap/main.go +++ b/cmd/urlmap/main.go @@ -46,6 +46,11 @@ var ( jsThreshold float64 jsPoolSize int + // Cache flags + jsCacheEnabled bool + jsCacheSize int + jsCacheTTL time.Duration + // Robots.txt flags respectRobots bool ) @@ -105,6 +110,11 @@ func init() { // Browser pool flags rootCmd.Flags().IntVar(&jsPoolSize, "js-pool-size", 2, "Number of browser instances in the pool") + // Cache flags + rootCmd.Flags().BoolVar(&jsCacheEnabled, "js-cache", false, "Enable caching of JavaScript rendered pages") + rootCmd.Flags().IntVar(&jsCacheSize, "js-cache-size", 100, "Maximum number of cached entries") + rootCmd.Flags().DurationVar(&jsCacheTTL, "js-cache-ttl", 5*time.Minute, "Cache entry time-to-live") + // Robots.txt flags rootCmd.Flags().BoolVar(&respectRobots, "respect-robots", false, "Respect robots.txt rules and crawl delays") @@ -147,9 +157,12 @@ func runCrawl(cmd *cobra.Command, args []string) error { UserAgent: userAgent, Fallback: jsFallback, AutoDetect: jsAuto || jsAutoStrict, - StrictMode: jsAutoStrict, - Threshold: jsThreshold, - PoolSize: jsPoolSize, + StrictMode: jsAutoStrict, + Threshold: jsThreshold, + PoolSize: jsPoolSize, + CacheEnabled: jsCacheEnabled, + CacheSize: jsCacheSize, + CacheTTL: jsCacheTTL, } } diff --git a/internal/client/js_client.go b/internal/client/js_client.go index 94e73bd..627b213 100644 --- a/internal/client/js_client.go +++ b/internal/client/js_client.go @@ -12,6 +12,7 @@ type JSClient struct { pool *BrowserPool config *JSConfig logger *slog.Logger + cache *RenderCache } // NewJSClient creates a new JavaScript client with the given configuration @@ -40,6 +41,14 @@ func NewJSClient(config *JSConfig, logger *slog.Logger) (*JSClient, error) { logger: logger, } + // Create cache if enabled + if config.CacheEnabled { + client.cache = NewRenderCache(config.CacheSize, config.CacheTTL) + logger.Info("JavaScript render cache enabled", + "max_size", config.CacheSize, + "ttl", config.CacheTTL) + } + return client, nil } @@ -54,6 +63,30 @@ func (c *JSClient) RenderPage(ctx context.Context, targetURL string) (string, er // Get implements a similar interface to the HTTP client for compatibility func (c *JSClient) Get(ctx context.Context, targetURL string) (*JSResponse, error) { + // Check cache first if enabled + if c.cache != nil { + if entry, hit := c.cache.Get(targetURL); hit { + c.logger.Debug("Cache hit for URL", "url", targetURL) + + // Parse URL for response metadata + parsedURL, err := url.Parse(targetURL) + if err != nil { + return nil, fmt.Errorf("failed to parse URL: %w", err) + } + + return &JSResponse{ + URL: targetURL, + Content: entry.Content, + Status: entry.StatusCode, + Headers: entry.Headers, + Host: parsedURL.Host, + FromCache: true, + }, nil + } + c.logger.Debug("Cache miss for URL", "url", targetURL) + } + + // Not in cache, render the page content, err := c.RenderPage(ctx, targetURL) if err != nil { return nil, err @@ -65,13 +98,22 @@ func (c *JSClient) Get(ctx context.Context, targetURL string) (*JSResponse, erro return nil, fmt.Errorf("failed to parse URL: %w", err) } - return &JSResponse{ - URL: targetURL, - Content: content, - Status: 200, // Assume success if we got content - Headers: make(map[string]string), - Host: parsedURL.Host, - }, nil + response := &JSResponse{ + URL: targetURL, + Content: content, + Status: 200, // Assume success if we got content + Headers: make(map[string]string), + Host: parsedURL.Host, + FromCache: false, + } + + // Store in cache if enabled + if c.cache != nil { + c.cache.Set(targetURL, content, response.Headers, response.Status) + c.logger.Debug("Stored render result in cache", "url", targetURL) + } + + return response, nil } // Close cleans up the JavaScript client resources @@ -94,13 +136,22 @@ func (c *JSClient) GetPoolStats() map[string]interface{} { return c.pool.GetPoolStats() } +// GetCacheStats returns statistics about the render cache +func (c *JSClient) GetCacheStats() map[string]interface{} { + if c.cache == nil { + return nil + } + return c.cache.Stats() +} + // JSResponse represents a response from JavaScript rendering type JSResponse struct { - URL string - Content string - Status int - Headers map[string]string - Host string + URL string + Content string + Status int + Headers map[string]string + Host string + FromCache bool // Indicates if this response was served from cache } // String returns the rendered HTML content diff --git a/internal/client/js_client_test.go b/internal/client/js_client_test.go index 7357332..715259c 100644 --- a/internal/client/js_client_test.go +++ b/internal/client/js_client_test.go @@ -218,3 +218,162 @@ func TestJSResponse_StatusCode(t *testing.T) { t.Errorf("Expected status 200, got %d", status) } } + +func TestJSClient_CacheHit(t *testing.T) { + // Create test server + testServer := shared.CreateBasicTestServer() + defer testServer.Close() + + logger := slog.Default() + config := &JSConfig{ + Enabled: true, + BrowserType: "chromium", + Headless: true, + Timeout: 30 * time.Second, + WaitFor: "networkidle", + PoolSize: 1, + CacheEnabled: true, + CacheSize: 10, + CacheTTL: 1 * time.Hour, + } + + client, err := NewJSClient(config, logger) + if err != nil { + t.Fatalf("Failed to create JS client: %v", err) + } + defer client.Close() + + ctx := context.Background() + + // First request - should not be cached + response1, err := client.Get(ctx, testServer.URL) + if err != nil { + t.Fatalf("Failed to get page: %v", err) + } + + if response1.FromCache { + t.Error("First request should not be from cache") + } + + // Second request - should be cached + response2, err := client.Get(ctx, testServer.URL) + if err != nil { + t.Fatalf("Failed to get cached page: %v", err) + } + + if !response2.FromCache { + t.Error("Second request should be from cache") + } + + // Content should be the same + if response1.Content != response2.Content { + t.Error("Cached content should match original content") + } + + // Check cache stats + cacheStats := client.GetCacheStats() + if cacheStats == nil { + t.Fatal("Cache stats should not be nil") + } + + if cacheStats["size"].(int) != 1 { + t.Errorf("Expected cache size 1, got %v", cacheStats["size"]) + } +} + +func TestJSClient_CacheExpiration(t *testing.T) { + // Create test server + testServer := shared.CreateBasicTestServer() + defer testServer.Close() + + logger := slog.Default() + config := &JSConfig{ + Enabled: true, + BrowserType: "chromium", + Headless: true, + Timeout: 30 * time.Second, + WaitFor: "networkidle", + PoolSize: 1, + CacheEnabled: true, + CacheSize: 10, + CacheTTL: 100 * time.Millisecond, // Short TTL for testing + } + + client, err := NewJSClient(config, logger) + if err != nil { + t.Fatalf("Failed to create JS client: %v", err) + } + defer client.Close() + + ctx := context.Background() + + // First request + response1, err := client.Get(ctx, testServer.URL) + if err != nil { + t.Fatalf("Failed to get page: %v", err) + } + + if response1.FromCache { + t.Error("First request should not be from cache") + } + + // Wait for cache to expire + time.Sleep(150 * time.Millisecond) + + // Second request - should not be cached (expired) + response2, err := client.Get(ctx, testServer.URL) + if err != nil { + t.Fatalf("Failed to get page after expiration: %v", err) + } + + if response2.FromCache { + t.Error("Request after expiration should not be from cache") + } +} + +func TestJSClient_CacheDisabled(t *testing.T) { + // Create test server + testServer := shared.CreateBasicTestServer() + defer testServer.Close() + + logger := slog.Default() + config := &JSConfig{ + Enabled: true, + BrowserType: "chromium", + Headless: true, + Timeout: 30 * time.Second, + WaitFor: "networkidle", + PoolSize: 1, + CacheEnabled: false, // Cache disabled + } + + client, err := NewJSClient(config, logger) + if err != nil { + t.Fatalf("Failed to create JS client: %v", err) + } + defer client.Close() + + ctx := context.Background() + + // First request + response1, err := client.Get(ctx, testServer.URL) + if err != nil { + t.Fatalf("Failed to get page: %v", err) + } + + // Second request - should not be cached + response2, err := client.Get(ctx, testServer.URL) + if err != nil { + t.Fatalf("Failed to get page: %v", err) + } + + if response1.FromCache || response2.FromCache { + t.Error("No requests should be from cache when caching is disabled") + } + + // Cache stats should be nil + cacheStats := client.GetCacheStats() + if cacheStats != nil { + t.Error("Cache stats should be nil when caching is disabled") + } +} diff --git a/internal/client/js_config.go b/internal/client/js_config.go index 57202f3..60f9804 100644 --- a/internal/client/js_config.go +++ b/internal/client/js_config.go @@ -40,22 +40,34 @@ type JSConfig struct { // PoolSize specifies the number of browser instances in the pool PoolSize int + + // CacheEnabled indicates whether to cache rendered pages + CacheEnabled bool + + // CacheSize specifies the maximum number of cache entries + CacheSize int + + // CacheTTL specifies how long cache entries remain valid + CacheTTL time.Duration } // DefaultJSConfig returns a default JavaScript configuration func DefaultJSConfig() *JSConfig { return &JSConfig{ - Enabled: false, - BrowserType: "chromium", - Headless: true, - Timeout: 30 * time.Second, - WaitFor: "networkidle", - UserAgent: "urlmap/1.0", - AutoDetect: false, - StrictMode: false, - Threshold: 0.5, - Fallback: true, - PoolSize: 2, + Enabled: false, + BrowserType: "chromium", + Headless: true, + Timeout: 30 * time.Second, + WaitFor: "networkidle", + UserAgent: "urlmap/1.0", + AutoDetect: false, + StrictMode: false, + Threshold: 0.5, + Fallback: true, + PoolSize: 2, + CacheEnabled: false, + CacheSize: 100, + CacheTTL: 5 * time.Minute, } } @@ -101,5 +113,15 @@ func (c *JSConfig) Validate() error { return fmt.Errorf("pool size must be positive, got: %v", c.PoolSize) } + // Validate cache configuration + if c.CacheEnabled { + if c.CacheSize <= 0 { + return fmt.Errorf("cache size must be positive when cache is enabled, got: %v", c.CacheSize) + } + if c.CacheTTL <= 0 { + return fmt.Errorf("cache TTL must be positive when cache is enabled, got: %v", c.CacheTTL) + } + } + return nil } diff --git a/internal/client/render_cache.go b/internal/client/render_cache.go new file mode 100644 index 0000000..2fafde1 --- /dev/null +++ b/internal/client/render_cache.go @@ -0,0 +1,180 @@ +package client + +import ( + "sync" + "sync/atomic" + "time" +) + +// CacheEntry represents a cached render result +type CacheEntry struct { + URL string + Content string + Headers map[string]string + StatusCode int + Timestamp time.Time +} + +// RenderCache is a thread-safe in-memory cache for rendered pages +type RenderCache struct { + // Thread-safe storage + entries sync.Map // key: string (URL), value: *CacheEntry + + // Configuration + maxSize int64 // Maximum number of entries + ttl time.Duration // Time to live for cache entries + + // Size tracking + size int64 // Current number of entries (atomic) + + // For eviction + accessOrder sync.Map // key: string (URL), value: time.Time (last access time) + mu sync.Mutex +} + +// NewRenderCache creates a new render cache with the given configuration +func NewRenderCache(maxSize int, ttl time.Duration) *RenderCache { + return &RenderCache{ + maxSize: int64(maxSize), + ttl: ttl, + size: 0, + } +} + +// Get retrieves a cached entry if it exists and is not expired +func (c *RenderCache) Get(url string) (*CacheEntry, bool) { + // Try to get the entry + value, exists := c.entries.Load(url) + if !exists { + return nil, false + } + + entry := value.(*CacheEntry) + + // Check if the entry has expired + if time.Since(entry.Timestamp) > c.ttl { + // Remove expired entry + c.Delete(url) + return nil, false + } + + // Update access time + c.accessOrder.Store(url, time.Now()) + + return entry, true +} + +// Set stores a new cache entry, evicting oldest entries if necessary +func (c *RenderCache) Set(url string, content string, headers map[string]string, statusCode int) { + // Create new entry + entry := &CacheEntry{ + URL: url, + Content: content, + Headers: headers, + StatusCode: statusCode, + Timestamp: time.Now(), + } + + // Check if we're updating an existing entry + _, exists := c.entries.Load(url) + + // Store the entry + c.entries.Store(url, entry) + c.accessOrder.Store(url, time.Now()) + + // Update size if this is a new entry + if !exists { + newSize := atomic.AddInt64(&c.size, 1) + + // Check if we need to evict + if newSize > c.maxSize { + c.evictOldest() + } + } +} + +// Delete removes an entry from the cache +func (c *RenderCache) Delete(url string) { + if _, exists := c.entries.LoadAndDelete(url); exists { + c.accessOrder.Delete(url) + atomic.AddInt64(&c.size, -1) + } +} + +// evictOldest removes the least recently accessed entries until we're under maxSize +func (c *RenderCache) evictOldest() { + c.mu.Lock() + defer c.mu.Unlock() + + // Collect all entries with their access times + type accessEntry struct { + url string + accessTime time.Time + } + + var entries []accessEntry + c.accessOrder.Range(func(key, value interface{}) bool { + entries = append(entries, accessEntry{ + url: key.(string), + accessTime: value.(time.Time), + }) + return true + }) + + // Sort by access time (oldest first) + // Using simple bubble sort for small datasets + for i := 0; i < len(entries); i++ { + for j := i + 1; j < len(entries); j++ { + if entries[i].accessTime.After(entries[j].accessTime) { + entries[i], entries[j] = entries[j], entries[i] + } + } + } + + // Evict oldest entries until we're under maxSize + currentSize := atomic.LoadInt64(&c.size) + for i := 0; i < len(entries) && currentSize > c.maxSize; i++ { + c.Delete(entries[i].url) + currentSize = atomic.LoadInt64(&c.size) + } +} + +// Size returns the current number of entries in the cache +func (c *RenderCache) Size() int { + return int(atomic.LoadInt64(&c.size)) +} + +// Clear removes all entries from the cache +func (c *RenderCache) Clear() { + c.entries.Range(func(key, value interface{}) bool { + c.entries.Delete(key) + c.accessOrder.Delete(key) + return true + }) + atomic.StoreInt64(&c.size, 0) +} + +// Stats returns cache statistics +func (c *RenderCache) Stats() map[string]interface{} { + var expiredCount int + var validCount int + + c.entries.Range(func(key, value interface{}) bool { + entry := value.(*CacheEntry) + if time.Since(entry.Timestamp) > c.ttl { + expiredCount++ + } else { + validCount++ + } + return true + }) + + return map[string]interface{}{ + "size": c.Size(), + "max_size": c.maxSize, + "ttl_seconds": c.ttl.Seconds(), + "valid_entries": validCount, + "expired_entries": expiredCount, + } +} + diff --git a/internal/client/render_cache_test.go b/internal/client/render_cache_test.go new file mode 100644 index 0000000..b7812d7 --- /dev/null +++ b/internal/client/render_cache_test.go @@ -0,0 +1,355 @@ +package client + +import ( + "fmt" + "sync" + "testing" + "time" +) + +func TestNewRenderCache(t *testing.T) { + cache := NewRenderCache(100, 5*time.Minute) + + if cache == nil { + t.Fatal("NewRenderCache returned nil") + } + + if cache.maxSize != 100 { + t.Errorf("Expected maxSize 100, got %d", cache.maxSize) + } + + if cache.ttl != 5*time.Minute { + t.Errorf("Expected ttl 5 minutes, got %v", cache.ttl) + } + + if cache.Size() != 0 { + t.Errorf("Expected initial size 0, got %d", cache.Size()) + } +} + +func TestRenderCache_SetAndGet(t *testing.T) { + cache := NewRenderCache(10, 1*time.Hour) + + // Test basic set and get + url := "https://example.com" + content := "
Test" + headers := map[string]string{"Content-Type": "text/html"} + statusCode := 200 + + cache.Set(url, content, headers, statusCode) + + // Test successful get + entry, found := cache.Get(url) + if !found { + t.Error("Expected to find cached entry") + } + + if entry.URL != url { + t.Errorf("Expected URL %s, got %s", url, entry.URL) + } + + if entry.Content != content { + t.Errorf("Expected content %s, got %s", content, entry.Content) + } + + if entry.StatusCode != statusCode { + t.Errorf("Expected status code %d, got %d", statusCode, entry.StatusCode) + } + + // Test cache size + if cache.Size() != 1 { + t.Errorf("Expected cache size 1, got %d", cache.Size()) + } +} + +func TestRenderCache_TTLExpiration(t *testing.T) { + cache := NewRenderCache(10, 100*time.Millisecond) + + url := "https://example.com" + content := "Test" + cache.Set(url, content, nil, 200) + + // Entry should be found immediately + _, found := cache.Get(url) + if !found { + t.Error("Expected to find cached entry immediately after setting") + } + + // Wait for TTL to expire + time.Sleep(150 * time.Millisecond) + + // Entry should be expired and removed + _, found = cache.Get(url) + if found { + t.Error("Expected entry to be expired and not found") + } + + // Size should be 0 after expiration + if cache.Size() != 0 { + t.Errorf("Expected cache size 0 after expiration, got %d", cache.Size()) + } +} + +func TestRenderCache_Eviction(t *testing.T) { + cache := NewRenderCache(3, 1*time.Hour) + + // Fill cache to capacity + cache.Set("url1", "content1", nil, 200) + time.Sleep(10 * time.Millisecond) + cache.Set("url2", "content2", nil, 200) + time.Sleep(10 * time.Millisecond) + cache.Set("url3", "content3", nil, 200) + + if cache.Size() != 3 { + t.Errorf("Expected cache size 3, got %d", cache.Size()) + } + + // Access url1 to make it more recently used + cache.Get("url1") + + // Add a fourth entry, should evict the oldest (url2) + cache.Set("url4", "content4", nil, 200) + + // Cache should still have size 3 + if cache.Size() != 3 { + t.Errorf("Expected cache size 3 after eviction, got %d", cache.Size()) + } + + // url2 should have been evicted (oldest non-accessed) + _, found := cache.Get("url2") + if found { + t.Error("Expected url2 to be evicted") + } + + // Other URLs should still be present + _, found = cache.Get("url1") + if !found { + t.Error("Expected url1 to still be in cache") + } + + _, found = cache.Get("url3") + if !found { + t.Error("Expected url3 to still be in cache") + } + + _, found = cache.Get("url4") + if !found { + t.Error("Expected url4 to still be in cache") + } +} + +func TestRenderCache_Delete(t *testing.T) { + cache := NewRenderCache(10, 1*time.Hour) + + url := "https://example.com" + cache.Set(url, "content", nil, 200) + + // Verify entry exists + _, found := cache.Get(url) + if !found { + t.Error("Expected to find cached entry") + } + + // Delete the entry + cache.Delete(url) + + // Verify entry is gone + _, found = cache.Get(url) + if found { + t.Error("Expected entry to be deleted") + } + + // Verify size is 0 + if cache.Size() != 0 { + t.Errorf("Expected cache size 0 after deletion, got %d", cache.Size()) + } +} + +func TestRenderCache_Clear(t *testing.T) { + cache := NewRenderCache(10, 1*time.Hour) + + // Add multiple entries + for i := 0; i < 5; i++ { + url := fmt.Sprintf("https://example.com/page%d", i) + cache.Set(url, fmt.Sprintf("content%d", i), nil, 200) + } + + if cache.Size() != 5 { + t.Errorf("Expected cache size 5, got %d", cache.Size()) + } + + // Clear the cache + cache.Clear() + + // Verify all entries are gone + if cache.Size() != 0 { + t.Errorf("Expected cache size 0 after clear, got %d", cache.Size()) + } + + // Verify individual entries are gone + for i := 0; i < 5; i++ { + url := fmt.Sprintf("https://example.com/page%d", i) + _, found := cache.Get(url) + if found { + t.Errorf("Expected %s to be cleared", url) + } + } +} + +func TestRenderCache_UpdateExisting(t *testing.T) { + cache := NewRenderCache(10, 1*time.Hour) + + url := "https://example.com" + cache.Set(url, "original content", nil, 200) + + if cache.Size() != 1 { + t.Errorf("Expected cache size 1, got %d", cache.Size()) + } + + // Update the same URL + cache.Set(url, "updated content", nil, 200) + + // Size should still be 1 + if cache.Size() != 1 { + t.Errorf("Expected cache size 1 after update, got %d", cache.Size()) + } + + // Content should be updated + entry, found := cache.Get(url) + if !found { + t.Error("Expected to find cached entry") + } + + if entry.Content != "updated content" { + t.Errorf("Expected updated content, got %s", entry.Content) + } +} + +func TestRenderCache_ConcurrentAccess(t *testing.T) { + cache := NewRenderCache(100, 1*time.Hour) + + // Number of goroutines and operations + numGoroutines := 10 + numOperations := 100 + + var wg sync.WaitGroup + wg.Add(numGoroutines) + + // Launch concurrent goroutines + for i := 0; i < numGoroutines; i++ { + go func(id int) { + defer wg.Done() + + for j := 0; j < numOperations; j++ { + url := fmt.Sprintf("https://example.com/page%d-%d", id, j) + + // Alternate between set and get operations + if j%2 == 0 { + cache.Set(url, fmt.Sprintf("content%d-%d", id, j), nil, 200) + } else { + // Try to get a previously set URL + prevURL := fmt.Sprintf("https://example.com/page%d-%d", id, j-1) + cache.Get(prevURL) + } + + // Occasionally delete an entry + if j%10 == 0 && j > 0 { + deleteURL := fmt.Sprintf("https://example.com/page%d-%d", id, j-10) + cache.Delete(deleteURL) + } + } + }(i) + } + + wg.Wait() + + // Verify cache is in a valid state + size := cache.Size() + if size < 0 || size > 100 { + t.Errorf("Cache size out of bounds: %d", size) + } + + // Verify we can still perform operations + cache.Set("final-test", "final-content", nil, 200) + entry, found := cache.Get("final-test") + if !found { + t.Error("Failed to set and get after concurrent access") + } + if entry.Content != "final-content" { + t.Errorf("Expected final-content, got %s", entry.Content) + } +} + +func TestRenderCache_Stats(t *testing.T) { + cache := NewRenderCache(10, 100*time.Millisecond) + + // Add some entries + cache.Set("url1", "content1", nil, 200) + cache.Set("url2", "content2", nil, 200) + + // Wait for entries to expire + time.Sleep(150 * time.Millisecond) + + // Add a fresh entry + cache.Set("url3", "content3", nil, 200) + + stats := cache.Stats() + + // Check stats structure + if stats["max_size"].(int64) != 10 { + t.Errorf("Expected max_size 10, got %v", stats["max_size"]) + } + + if stats["ttl_seconds"].(float64) != 0.1 { + t.Errorf("Expected ttl_seconds 0.1, got %v", stats["ttl_seconds"]) + } + + // Size should be 3 (2 expired + 1 valid) + if stats["size"].(int) != 3 { + t.Errorf("Expected size 3, got %v", stats["size"]) + } + + if stats["valid_entries"].(int) != 1 { + t.Errorf("Expected 1 valid entry, got %v", stats["valid_entries"]) + } + + if stats["expired_entries"].(int) != 2 { + t.Errorf("Expected 2 expired entries, got %v", stats["expired_entries"]) + } +} + +func TestRenderCache_ComplexEviction(t *testing.T) { + cache := NewRenderCache(5, 1*time.Hour) + + // Fill cache with entries accessed at different times + urls := []string{"url1", "url2", "url3", "url4", "url5"} + for i, url := range urls { + cache.Set(url, fmt.Sprintf("content%d", i), nil, 200) + time.Sleep(10 * time.Millisecond) + + // Access some entries to change their access time + if i == 1 { + cache.Get("url1") // Make url1 more recently accessed than url2 + } + } + + // Now access pattern: url5 (newest), url4, url3, url2, url1 (recently accessed) + // Add new entries to trigger eviction + cache.Set("url6", "content6", nil, 200) + + // url2 should be evicted (oldest access time among the original entries) + _, found := cache.Get("url2") + if found { + t.Error("Expected url2 to be evicted") + } + + // Check other URLs are still present + expectedPresent := []string{"url1", "url3", "url4", "url5", "url6"} + for _, url := range expectedPresent { + _, found := cache.Get(url) + if !found { + t.Errorf("Expected %s to be in cache", url) + } + } +} + diff --git a/urlmap b/urlmap index 9557cd65e8a5c05f32149f29b80216a9bb51473e..51ddabf5366653a0b15523b53d7101993317584a 100755 GIT binary patch delta 6077974 zcmb@ve_WJR7C(OPGvJ`8prD8-%z%c!5ETm0>_JtmPGDHC9@d8y z!_pL`N!gAeX~MdG6Rs%!7Vqtl1WIx6QvNN!qjxzbkaLqko7^`7Z@2A&_{X>W;ST7D z*LFx)zvHt~DgDfW0z?IDm&HK)0J+}))>sk=G3g?6M2#1RzeD&!ouM~0Tfak~tWhcO z+fcK~!PK$1P!ZtGCe<@jeG2_3rvcbHL7L!Pobgc{YR2hdGIkxLbf8iBt*s+dkiXNW zxmt9vL}hlAZO{y$q)~u!vt|G@8l77^1cec3@IH)Q8zwB7>a+k8JH5RwA zuBC~!|G>9PhzWWzChG*xjI&TlWly |{(IC<>U!luLuf!{=unB hV>TnWqBjn8m^gG2tW{InI1fu`=F2JIbla|qx0?Cm zvMKmiLJDL0DL!46mR=R_pVC$*$$#@Ht?xnpcaPh6Nk_6;kP2g8xJ543?RFPh}5&Z zKYkGf76oFUy^EVElNf?9ym(2ias9 z5MznxS6i$K7GTXV pOrjU#xB#a{{drunRD6#itO@G;gJyfpmF7%ooy zC#;8oKf@44n5&EI9unT#?Fa z_Z+42(mI2`^eKzq@dUkyZa5@iSacma{`!ge=YP1?I^Ch;J9ZoV&6v3}u`goN78Ea8 zEty{|*AcI+T&^R|M)L4K!_*cChG +gOu}1i$0gAq&6NR~s7rnmz0X5*?3&BeV%z7`Dmo_{>tzFwghW8^OxSmm={+H7 zcoYO@w8RoaMaFtMxR>cQH)!BfTC&`}R)g6=kM`n%aE}fj2;yxDgkFG#2p()`omlw; z9~C7Tf=@)x5;*k-e&ACx7^w?_Te|vD4t4Os#n=mVu+g-fa=ZNE%p$;_cwB#B?45Km z`8*#_3qXrwpLp#&FL6pDmM(H{D4&R6y*1+rcq1#-9HFrU$I@2;;wB>gt@ibzEP|ua z xeoKX>}Z~U zEh=I$T42KORY_^c3Wj<4XpHiuf(V4ogN9B|hU#O{8dQOoY{_dFt~ZJw7z2)B-o6OE z&N{7*HIP3Bw+MBc4TeE@+4?-R&?t%9kBWc&$cJjfuu~-eix16$LBSQOZfXY*Z=jPr zEL?Qvc+WjpIMAp?!72;alH(aLGPDM)`WL@nPG%dpp|eBc-Ud$S>xw0h>8DTl8UcO5 zaV5sdhiLgA#=}M9Sf#~d%Ltxj@qY$5qjCCNd*kqFg6CT5KMBC~_b;CSe4a+z&H8l_ z#TW3Eg_RJk6_=dWT+lVFY_0g_0-r?wP$RQ+6R9n{FZH>xY>k-Ia-*PbT?;NSbdU;S z5oa#)zY5z|O86kxNcC5L(6?;_ZW!qyfK#+tg5AtDQe60nKU4_2N*Of+m71WHC!w(? z;XIMkL?_ziC@)zz!JmwH5Lzfh1^c>NiFq#Z>@1idw_|a>zuz^dr(a6}BL4!9GrIEp z3;5!eEQ6_XEGfI<#D^zV#%VBezzqM!`_Y7e7kf+o&EKE}%lk-cp)mZ+Hz+09V$09` zHhO;bGrxzP$-m(JaVhj=L&daTcp8Oah PAW3adm6+jfo)arf*DOT4TF)$g5YCOj6n4rqIuiz-ZtmP7a ETx`#zIGA#AC-KZg5;lC0b+ejunbLZldY;)u?g4f2y zXI!~E0lmihm!z)zLWXQ!`(?R`d&@T^iNOk5)DgsFMHxRXfL#LY72qA03gi_VZe$~` zX_1b%?5aj#7AVo&SU*Gej<1TZ6s7kJlIQHp2LD-@7b=(es|NonjOkfhH}x#$RV!l` z)3i0rI}5mkQwE$em>`NBH^BS^=3RinT!RuHg^A#0xGw~Sz-NV$#B75yFxUlQQ1X)G zaDp}L`|YCRx&Rq;2Q|8j%Lb)ycA%de{cM5>?2Q$L5NVtPH&|r_@C%)jALJpobcMJ$ zp2vyOFlB%i+o2&7z83=Bx%eY=EYKL%++Bw07TGTc`sZL$-@H!r(eN$4<%xaLl ehxUUGP(1SUl&z{A}u= z3^&fnW{nX%TO9A8EFepLw^4b16f_#4n%*37zx_i+>QFOfH-F@hV)zo qIj3Lqk`L0 zl)>kg)^}3UW|%_w_%+%gJ|4xBh48VO;Nat{+4{Wsq>H@oxsu>Gyg3N?*;%aW8T`3u zroP)tq(my_QD_N1o;Eq3(23uD6Y)oMBHkcz@k}H-DeDeX>efXn56P(5O ^AW0#j6QE=(=|GcTza}8hpFw zp+|=9${nN!*ajLVrgg^<6Lpf%`tHhI#xi6!N8?ja-7)p4P8s@|5~D12$c!c$_bga+ zy1a7-oL8zISf@^dIjmFe{ie3-R2%qB>(m*1)qrTzNkMCjGG59lWI4chNNy1-!Nj~+ z@GA%Vi5_)(VwKJKX%g}-((~))^;9yIIFzB6-Q AuqQ%0b@_(=>sPzeUPD&StxPzQBG@T5=*qE zE;yjvh+&??6OCX8Mw_hP6C4gS-dn6{fHM*}iEbu1HglKKvlp1secZnz|E*nz1->zY z`pv*iWIDW$jGKnl(EW8fFY1K?7uE(kL+7i*vMn?lBf832_X2*-%0)7`vyZskS6M3i zLzkr&Co1rQh$Y;NsZCVwpqLCNw%UQ}O5zJ 2&YM3OR=ZWMUg6)g(9lPDwFdj*tr35rWen7PQ{Mq;`sGAZ{{#> `ubQPZVHx^#FL1*9>Jv?mpptm%@MK2kG-GJz72tW1q-pq}*^Ccm^?I8+|8 zw#e=HWMyFY9GD-+Jp8l?4tFSBI=}Ar6s5NkcN}*c$}+6iU8b_cx8Z2XJU$#bufH<9 z1JTD#*Ny#^ G=UL-ig));{I8YUN}N`m7+<4X;W&vs(3399-m z!J)#R18ySv|I~D83BjRzzW}ZeOI?1dG8Z3+Lx1V2N$mibFqw(5iq-+jdvaph3`$+? zK; Y90=Vwbi;(b} z6kE_&$p$vM_6>m++8_{IQV@}8id`y3$y)Jbno=Uuj iY09jgq#Sfx8TZl8uf){T zlt%n&sIpR1R-9v>7>a4&Af5IN5mD*nUrW+MM0z?VXbL 5_>^0&O*^{3Wx4#KBH4ojfMWz@uy I6{fl!qh*H5{?ndt3lk>5z1VNOTpdz4CVD;#i)_! z6w#-W+>y%IV5VXuCOVZ!`Xk| ?JOb#6mO^OBk^+Ia3*)N@%nuet`uWD{A9r zoPlhC1Gryz0Ckwyl!=;zOiRr19m+jc%ndT-l7ng1didFyR(->TV1XTBDQV2nz^*Pi zG&-zq%_wEFk|g!ES2kwX@|vF?gf2wkD};DLZ(*t9MNziWmlOtDY}$G28A8Ig?hp;B zkv#>i%EqXon~iKvX59|d;AHCX+Z&1E@a>SAB<713^K%qCWg3~=AdclI@|#!qd3Icr z%~2kbJ%r~YipMDSLP_8gZQ6I!8Dq;Vo?p*I`$QbSN4^1 FGLdkCKC7DME)zvqq|f#8R-!q{l)z^$X_0-Gmdc- zGsj_^bjHQbafn}aPQ{^d VEvEs0kUTbKsu-9Pqn%9JnqRQdHrS;$b z7duKc n7}{Ft$`iZJ4-T2fheK)6>b= z`GZ(9=q5bi_=;?bYQqsJ1xPdtb(q*~L1))NpY2#!j$4%e>F9{N1Yggl69_XLxswLv z5YjJFtrE0g#YI;L;-GAe#fbDgrAIzhqEBw)qi*N_fMy99-Ef;(3c9iWV7T5ax{E+0 zMdg4s;2Xgqcw$wal18KNwl6FcyYm!tOm%; (u#INRT#~sYBxN z* iH5^$TTKzTVx zW7T*Vg+NK_1nkSmjL~}id^oFW%C-0*kn4~|mLz4cit-7tx3X23n;jFB@3rdbR OAzd_X 23 zvRE$Ct~$|j6ym1pM8%vdGayX9CH9@LFQsfZS!{{gt#eIGlC5)H)x9`AJ>3rnn-j5= zO6P2pSTj+{b7H`tpo7$O%tu;~H^3(`;||c8H(EuY>`jyf(rRm~aNV%x!OajZJ+2;Y zHba6iY xPF2|hmY9E_Daj>GZ>xdOM zOliA9pNw+qo3U52w Uaj%XpjdN*`h$E+J%q^wXhL40DUtDTBL>M#@I z?bt1znWprX>DhJSjcLkjIS?1-C2pkQgesGI2sg6)UcAFIomHjb59MBlD=iyvkM>71 zuZqxMR7cF4P7W9mXzVK1OjrI7Y40Bwb+P@AzxFjRSnx-vsHmv0f{>EpFGM7D0Ts*4 zOuJTA?y?{n6_uG46{wgN8M_y2O1g@fS$fOO>~37Ui9b@yO47Rhu#z8|m35`;UfpZO z_j%5|Ubs^4&-e5Ge*f6VYv l3+nz6p~i##6KP+pTPYx!3DI;9+wVa-RxZ zJ4e66rWB0%szqIaEo|ub&z{MBhes7M *Kd jnvP}(V z?tR+bOK`aYH(}YN+D4q|rQJe$0$WEDaH+9eT&Q1{prH`SO8ePXxu&;u`Ts F}5$ zGsF^16(p3O`?GS)DdWA((LU~Mlxf0ZDZxAx`)gmD+&W+Hnri|4CjiDlJ>kq~GQ{V~ zCMb0iG`(mS<`gn)QDp_FjPkVuF6|32shYrK$^!jF8z;rwin-UxD~O)5@>YFDtX0PV z)Ugx?JaWLML=2PfhW9N0)jpc&>;atx=&SzA>>_=vx&atQv1LX2qEsqH7PMw8Kbk=< z1BJ@r_GVcdJD_k#yiC4L9~jFc0ZpWll1MiZm$|nQAhL1cCgK^2CdhCp&33T1G>~f= zb(T~oMqb7=%I(+2@W%1B5o(W}=t7;sY~vb?8 (|9vjGBWc zSx06FV;ZnHSd-Q*7KE{m%&7nJ>O1t&s>g8by5bI6pA+XI@Y)^v3}phpK3#S!#ymqs zU{-mo7#)xawV%!cA4CFmvY#rMdZ&I@G6moQ#rN>qF@K!I$p04i@D4KX;Wgs|&3Ec} zSOhS=g|G{y-i@^<6}=U37T&E7ibf6Id3dnm5nO>x3G)go0$cCaGhp_l6x4lsq5hd2 zqYlTY`^rQ2U?EERh!ddVj21}nl=Z~cS_7_c*Ld bYpKi8Rf?bd&pRD|cTYO*}|BY5S=W zv>+jp4P>){thF;k+nyK2L-V2?n;g@#ifdX}A>;0YA;7|P&VBl{o{(QyQ^)bM6rc2h z11*I+$+zy)lhW}YuQe%6CISrFkmC$qeHAmh-(qB);=$lk5YJ_gN66Uw)y#;|kR~(l zx9miTk zTm8 p;bDO zPeZ;&SOO9*0U0P 99qn1MZ=D i?j S`7ZLH}jPnKh5Gy%zu%dz{ca_PTX z^^RN)BM8+?rnu7O5UZFWy$(p?*5!JS30C$*(18%hLf6p|SX$w*BBf3Ikm9)xh+Hy5 zP`DWgKn%*6kLZq(7LZN=;=&rzI(2Hr)7O1W=N2~LzgE{Xu;~#!%| Sp-p~=*3Xv!sq8VOvKc2!Ds;z1nYoQ%!_drT zoZM5P@A{wWmDP{y)52p>RbHs89xle#mh+?FObR1@klXXw%y_B`FWvo2*1g-w4YcOvlSL*IKt~tJs@B4Mu zSXEC1Xr$Ak#HLDphROu%bbY%_z@kOVkCZW9eNs43z@;+smM8T7v{%;BhQVI1K8#F& zob|jH8-S?POSF4pJ=4zX7Vwo1(_RN1y`{N=29wt SSuNqDm8V^!0`tf?MfFcLL!QHA%6%s+R4)cGcb73ID z-%1m)D)lYd(3kymKZRKV!VBlJdda|_^>HJu)?I |Zln13jP z3urgM#%47;r0KZnOus9dp`*PCkyvo^GNG$%t +jJ*8gJ9 zLjbRc$>Jwr$9K|{cUg)&_9T>s^C+JO^(o;g{pIMTIN*@2rQ^C=ZF4?~lxtD4`6+#% zodfYvGD>1z Tapn47LYgV?@YLp=WB$qc- zV~*k8KsM{vnWtl$Ga=ugrq6@Y`v~;-{a8j&F7lUaeiko^5Q(fMg!(TB|3&{Px&Z~z z=e|QfG7Hz!Zbs@_wN2atB{Hyet=_2HPvAnpjS#|eJsJLp_P_KDq&%m;thV2&8tl|u zq1NHg+5@T2>petF5-`{YgU&uv3MWJPItf36Ih!!L5op2d6tZ-a-aBOwQU=))e1m>H zc3?u7_G&z)Zyi! zH}N5YMhFsz>tgGg87DWqs9%vxnJopK@p2|y^iYNwC!=4|XK-eynC#m#0BB^xoRkwH zyUjRR@sd7#PCPPbu*Nk)sTrJz$sM}$*B)$fj|xA?X)rA%BEBghC!-1Ovd>{maXifC zWOC| FsWr2uKa6;=ub_G~sC7bqaMgamT7 z=!4=c=pzX_2<3*_=f*p$^q&}@VvAxj28 aSG}vv?#RIE8oKVBmofOV zGRTOZBG Hu@7ZZWv%2a+04{GNdPz^^Bjf`Ij8v!e?(9Qf}5L{llK zApc}e2Z1S_%$}cI&OXXsZDntt4W{0+ $~Kl*;XZNO>t`=XO17q(zV$9pJR)tc)huXm*3s zXrr+)9k;F!!9b8}k?u69I`VbZYqWZlb6;27#ewSAQ5n-4DaS$s*gJK!b<5Ph>9sV8 z__eESJ_pF^jO3$1eyznOV*~oQ!g!J$&Vj7S7mWjLu_U z(*b?Bjof#z#2?KErGh9*Hna)&0u{gZv(mfIP(0WIqC>@ZA}dAdwjU@S8qraF8AYd3 z=1)>QjC3Dc`4d%sDo=9Y5WbUPM*eIptmX|=(esgVQ?0&HO=z>C zUJsyymppdtZ<;P7)IGUx>ONBG{o40k1IOOfuTc5m2qjbB3hAtcZ^6cid@b5#edO7< z^qX_g^Drof`G!A*O$tnqnrpnTPc6(laQU#NYb(#CnAjR(~u|nB~++4*6lHlX9ni z7x9CaE_REm(+hMxL*84b|7j*e1oR_MVnTr=bf3&6vt~=)p >UKAv-BUUV zN!-!5AC9LVLcjVN|2Zwnka2tTtXQ*@Zzvi?3GOJqbk|?viI*jNU<|OpR`1bgOt(nT z5xM!lX83*%{I2JSW !OPf%2Iqs)>1Fx1;z`#=%rrF+^ZPHOP42=B0# gF&7z%&|k`I#`_UlV%0^oN4TBR_~ z!%Rw;crdE7HNkwxn8CEcU(li%BQg*AH0Lw9--2@rSm1POcN=c6qNB@n$&%IAR-Pua zE>P!k1@!+!9#lrW$d#Mqv6^%2fIg(HclQ8lEEu-H969qaayDw=Z6l}Pq#8K|7&%`9 zDEv)$B+WBNjs
k zVa1nQq*$!To;Q~n<*GKiyBDjbyNi^wOB*SQe#a@kyHH8UO<@0oIK+XU4szkU@W@D@ zPIV^9{qHJi=p~!0{h)qxH^xOv@(r !Rx&M7k!rbC((&d@=_1je+qKZ4PnZe+WObqUVWTF3g z0Idr+T2W&Fn!kr8s_zjqe9#OZGT5^nYKk`-%?LdZZ_(~LG$V9bigy5U0f*lC602fW zk^Jum`p`b~|4ty_#rm`bCLvq6>U{TpnSDr4im`xRMzj%kZp*^#;lPrp^bibHR(q=t zLCirA_-Q$}@<^yN)ZOq+)c zRrUHJ6HKg(`cNN|s?nt9>c*bHKaIxxD~h(=iSO(*ZS{AfOzY=8<9u%DDVueh-02MO z5h(jmzuerpt?wcC9D(lZ#L|iE3@IPMpVSIee1u*y1A)Lt@T^aX$k6-|w!yy0`(5WY zC%&`hAby(G*(o<{`aUiid1< zw~OyV@fZhltoYS7^4Bfh7KMZku*!c6J3bI<(K;pYtG$s_V1qL_9R=<{x(z=@IKM-{ z-iV!!R5a6zX8KMR;w}~_Bi~kFQxnhLo|=ls0g(2%EyedkrZb2}d(miF+yp13KP)s8 zH}w4vh4wUo!9ojBEtJ`U8rO{OHz_GgoAn;0q2#*>*f(Io_c^fV_&Rk!_D>W)(iwc# ziZ{u34aH-Mda7Ogl@y=i40;j26_m=x&!H%Wy;;?TS(GQ&nHb!OOf^Kx+m!a1NeLk3 z5(TtHTRBXoABQ(o17#Z46RPV&+b*R{CC=bmDpS37xl&OFP&^o*2JtIUV;}s;LD~kY zI hOmrg+Taz1qdUNAXw=bhF}D z+vJ%Ou%U3x`5k28*ZMmOjpym!><70kq7vZcj`-G4&3*9@+DU!5%5+?1a+{gXf-cAe zcY{81-APi>h`8RKft@GyNE=%D&Nq4$m4t4ud?3CfN&@^aRg$_(mVB$H^+!o~1jq8F zMjulqU>%Du3S*rjKll~~CjO71aQa*QeIp5?(OV)0`|_fkFY*7J!M^+$=Sxjd#!JUz z!Z _5TboO0b7kmG;S z*Zg6oQ$Ojek|`5rZ1$2316Im2=Yp5487J4B(HHz7l-Qs3RTG%JX2-+~1?4dOUmjxi zUcFitcBOa>$xVpA_cCpDBz{h6dQFr(`Ln)=@tq$C AH7C(v57+- z>Y%5`&!LVQ>ec!_vidBT!>Vw{S?r)u+tCYw+;jS)hV6Xd!(a4jo7$v74=?;pzdzEB z$=OeDlAo~)2eDSYHZeoSMhLgk3EmtgmqiGZmz|mNY=pQ=@rXB57DtL1rO(4uS6-Lo zdjyt_$wr9b8I!dJW1M%HvX-q!J_qu_^WeZzm~yqab%$&8nxCNLV6_wF8&C}+Myxva zZRI6LO)PviH(@HX6d=un`#?D+`E+MsLI(klVKY{GI*M x53)k4vz1 z!f`c8K7ly?UzQ{nb`nF{N^a^TX0!!bJBdYP{bwbiq_Zf9v7uTAZq+2mi5v|M83>co z24|h&^3qGh<#CKc!zI4kmjW>izc|zkRcRKNEQ^=RE)m031r3+T9hZm#J2%snB$K1W z$9A& wl;&z=O=IxY2 z`vE`REprYH%A!1JRuC?Q+v&f13tst%f?Wq*$B$Bffm9AZ-UC9t z9>~M=fnYKoWmBSM BtK2$JOiN-A>#|T>JM}F*e pAa_>OHt`GZ;^Ufla4S47=;EQ8|7fdKy8L^Rc-U^H$qC6A *9nYL_AGu#7QIJBWdhSQVZxue7t#1nqa6%~-9#O=&*Y>(_X8f*_7N$$%{ zqs4Vy&3wM7d+~@B=-D85P3aXQ6Vt_B3x+(MF4Cq#e8SWk=X2mL%5&ea{Pt^b(_Vt` z&j`~-^l{h+I>&PR>b2+9p`9W9&^`cOh_--KUR)_6i^mAKWswYdu)nMtgYix{%>z!j z=VwMQtC5;mdf04~FAAbVc1aiA<%1bw80QDKR-HaKNPdwa;^RPSzm|xL)SyJ{Mpaa0 zL`apfW5H2%lxd|dGslW720_bET%h5Q&yN*j;(#6$HDzsn%!E;|ngg^u%ku;S72)7f zl#I<384QC=fO9g%#w0HOrYfFA#o_C?0>xkGqpi-sk4u)zq|3#43x8R3xw!74f?dsm zvr+I~6pFl1@axOPfRPXo_1R_WZd;Q3*iOeiZg9KD+(BUoILtX+*$)7F{q-9gx?-g& zhQuHNPG||7Kp9~l#fkL (`@`gY@-$G8u*?age&iQ@OO(9bv1sU8)6l zVT9pkXK5b~9*U LxbRMU;(Qy~n z=<`s12lQ{(p+{?*0i%p0NK1^YoCyp$W3S;dFjGv5&g`$%mv~})ZX8qL{~Kat)GSaU zo~pYhPA;Bh6~VE^b+g1cRirtjNJ1}pnu?$WxGJ@eoP8aNG_a15E=#Wyt71?;EGc8y ze(-Q3 T7hLZ35Q-YM!s;9$ZLy?x>>xRLV)t%(EJ>Z{oLD%2^9{_11^OVCA`++ zA=hYA*UTgBw+IJg U01QNS=qndt$SpXm@ z%` y-_H#PUOB}ZZ-Rj8iC>Og##?Vl}Er;GH3W zT>u;pd==opS1$3CEgTS99GA%l^2Fd+C%@d)&xdQkeg65h$DnOC o-Laeh|x(kByFzAWI1^Td1Fb5 zSeQ+RzP8hgA{2Hnmg8^5h@=riTK=Y6q3m(NH)CaV5f;e=F|R1MtrXW#oQpA{Z|-yy zV++bY=ULb;&tjEFAP) a!Csa~YSTFBxkF@(w~9{+$H8kHj5KrB+HKmX$>-sM3#meR3Q M{w2uVMxzIIcvbyOb!r7L^w_su&; z01sdvl=LGD-^In^iexH)>A7|}M9@I&xSjj2Dg qTySRS(^9wP;JF_T|L7WRlN_%Zj0;s0ae+ fpWN^){ldSd1Z000`Ql#R9VpY3lLmzO|=Wd+}=5!+9GA+zx+g zhWjQ4j@(C939DG~16ZAzfk4#*qERIgN+l1Pm=hMSJt&5ZCx0&53+{Ty1N&;L(t zb;^01vuqc?8S!PLyv&!~9uk|0FfCeQFS+L-@v^-R2-E4o#ScT!wxv8QHl`99^I(7I)hJI@gJ#Gdl2EDBX|W2 ze;4H}vt2bE_n>v*1C>M~3_Ce{iKrzydp*fhWXw|ZBKiu3iWGTdskn=p-nRI#q3OG6 z@qwm?E yx}jd}>AXns7_Q^3c(WUD_si Aps+0OzV|>NVW3 zh-&H`b%}vZe-`U6R5N|Gz}wFR40V>S7Vo6gAV8iw+g+`a|6?`y7Z~e9{a?&Ha^aJZ zStX?2fD${Ogv>Wl3A8>bB5j;RM2eCSMwCRW@0n5znrYRNj9Mtcz1SI~Z{FMso;@la z(;1}~fsrAjvfyRH`HC)>{w-_{NenL@^nV>5I4{Mkcuo~XJ(~jCo)Nk}nwkHeMBeV5 zWnh3{V2r@m$recrHr!r!HN|7FtrqAYqU4+MyJ|5$oy)$d%BE8u=o>xSl^t%DU2K)T zs;iv)tmqx%qEUNX+2@)Mp)dh{F
hvRXS zajhuOtH;RS){6cqHB?~bST@u=0BF@?uy#(F9NY=>(^oca^ FX>C5yv)R1JM0Jn+prF~w$gBbVd(F*1EU_<{r9j0u#i7w{f*Q6-7GJop^UGU!>T z6WFoXASRV^K K_;Cu34}UEdZ`+U4Lf;7c!HvJHeC$Q=OoXkcZ=)8$Wdt^B25Fi+ zvq4O=n~5^_c^J=3jO3x`p~snlK+Z<&nvhlmS yd zgrI}xbk>F5WO2eL;a}q+@$rUb>8oH}S3EE(gM>k*PbaX%BPEs3ncw%FGRw??I-b|8 z9;T=Nu4cSm1;p~sOv%WGmuCrS{zq^fNf+Kzg3A@2A&)ZuAIl16Ut<1m&I;yrHvb>Y z3NG!W4Wmsc?*m7(f`9LL;h~Go(RgzQ@h)T{#PW7x-wx!Z`!^SE6N9Ex?WJhC!e(Od z(d<^)x BmAOFo1GCZBLVqF zUv^uFMA*erwkxZdVJf{|OHA`QU{|&wTtn%X(YPwv_ABONl*b17aK5A $-SLsa;r8HOEG zm63g3$DRn6sY`*oyBIf^iEFaQBdCn7At*slx#vz&*APJYR$;mCb wMdFQ`?_$ T7blS=K9=uj zcm?C}CBz-Ym~;{O$}X2hf5TeB>^Qjrf!HMk-8GUgD1eZ?>w9R)R)*7mgBUL({Gd3^ z3}Aw$Itv%#*iiu1kOr#E?~tbgV$k?%WGld1DQpvf4aLmKHi2)j%SJW<%2S9uY!g5p zvI$V!yiM}jH((QZp4Z-)KGJ;HCh&zCMWiyyH^6^Z^#|V&jyMxB-$4+r5i|OLPw}QK zPR=#3MX{IcSc~RaAXEPU *^Lip)nOFaEJ|r?AI-9L6HNcvCnE znbz-(XGg o75B4@U5L;ue+OqWu+SRg9PKt?*?k{Ef 8&`+<%DWImqtSZo&at z!1@3T*8pLVk8c7lCToB~CVHFlK-rmtuZzp-GU_cT6C7BbC}+L}Bx)!n zK1r@XfaVm)(%g&zxH^I#fr5 Z}AjzK*XOPj@(Q7LNYAVkZHb z?bHDH0W@PmnFxB6m)U*g^-3J)C4_MYlO5`9EK~jkm5j{xb7+1*Su)+ze^+c6f8B9m zbnx;+w9oJoSESd8fu)=qwa}c_fq^3iHDum=5z!O=M4m8dw*pJt*nT+jDaUEc&Ax zY5F2R8E3NZnjJia1+x*(MwlFR84ln+hcekUT`qFoMeDuP`on)}#SPB&S9g~!b;3|S z0xQ$x`8sh$oR#PG>u}?oi4&dkHSv|6U06w*ndFvT*dAODs1DRb$EfJS8WS67-z`>M zf?~MubRrSV>#2~>?G|I($9=b3{AV_$yE4PrTc`{|ki&Drf|J+a=Dk_1@)tB)Ybrln zLWJGXiI=@}BOO;B-<4gh6;_G9mz_LMf{AT1eJ?Ci$mDlu{pDN=fG{{Z44&Hy!#Yzo zGa^Ip$#cHfRsB!~SAE7V5AGE=(E7ydS}X^@Et1BgZodnppsyP)+hl;?INaL9UuYT7 zIJ~Z?c^NKr75y(w2v==~qs-f)f2k8|Ud!9#s}Hb*#~3+|v9# SDKdW_z}z~)LM7d(%QyUY_8GR)OyKf`$#`hC495M6-UR?c!3oM&R`8#kx|$c zXJ^!Dd#DDeb6jk0@G`Rr@gy349_&-`_8@;NbAxdPyu85W6fUj&0~i%J6?8(2=K;o< zD(t4~{N!`wI^RH8-Zw!O?#DhNI-$iiXEJq0i`z3 z9gp)H1Qn1UN|{%CgN--~H}EXXg@pb0N>P~3XGY6N0TB o$Y#u{@0lv+2;PR+<4MGGK@)&-1f^Ub^2%m6{w{EiqDN4fLCk%?j2sAa$BN}2L5 z#5ZLk(Ki(V9(0~i9>!QQd03W)=3e)%_=}xsg=3C62eG0eI0a|mv2aklKN^(AEf{La z-yhvU{%yaY2jVGyuQC_(qIislTU5`~!(6cYJu!YZmo?1=Hp&B&!m)N`&m&A_v#hdL zDc$wo6pwM6iSLd7X_Y_xn^}Z*k>%|7#k6P^N}VD|C`B+aiz{T{eK8}3+Tt$m+-9_c z+SlT!fx+qncuY(Y75Qh`ZA3hzlmdBOdeZy;L9iVHUn #BCXdKos;t){>L|19 z1F9@eaVo+LrFgLOJ&3OYQIha$AcWy*y9AzCU+;$7zKZi|i5e?94l0AjwW16f*B896 zS{F+RXx*zSp _Drm*}Bgt9<7VRcRX70;m=rjll#Ou7>>|7pKLrVqGiK};u`iWB2yara=4s- zSd1bUFgVR91!@gJ!rfKF@I{+(Jy3?+aTvqINyJL*B##~zuQCkmF374QSg$t>L`B+Q zdHM)kb*jl|7ClA2_K{eWOaW4xNI76x?&{d)7D@g$W}KY pacuxgf%!Oj^xtExY-EP3;j gy?$uY4}9HS;8xc?>g;`XW#NqvA0J>ITrQ zM@4VlHAcRBRD4a}ZC{8x=sVy`fh(**BRbu_ P9uVX$8Q*7`c76Dl;_lb%UGw9!wxLB~dsHJ%%c{WJ1S-8KogS%9B7 zt}c$_UPc@N@c@X&rlrlH|3IC-`Ni_FM$tcJ7nscPh47I|PyXP`fT5>TgxuLE`o)$Z zpkdRYa@vJW2QyBdZ4{4|eh2QmvTIl0)UHd{e1G4J;P=0_zWxq=kvNQX5JRVEL9CB1 zI#q5`I-Wia-&}|AcsST|Q*c$k?IZ7DhgS2``&VJgZncl5W)jw=JHmsN$XiAn?#g7d z>6Ev4`|IO|1imp!wl-n#l>^K2J0s IxE{wM&_}|{&tu3zI!$|E2?2AT| z85Wp_zIk$K?NX>t^uJW oO>etTq_6a!5KgsvOdJ_FdjcMobF+CAC-}&fdOZ}X6u3)~*tFsq$R2Fh;cg^K z&+MYas+DfTt;=I?!U-ujYB+^6NCv8q^3ros3|FhjVclf)Nii-#p*1DLhJ_>lKb@Na z$mtDa4Fok^r|t6mNi18;DrC+#2v7 VXeEQs4n<^FG=E>liY zUQT@@aA7nE;(2W{i5K#vu1$p9>1B9zBwz)Ycu!U^5lWb&qgoK;BOk5$6YjC8MZ;?$ z&$$5(#+mOGFeTMEV6~HBV$X;# FB~gwz0}H>-vie)$NahBF3Vv** zg d> dL~O>#QDC?2B#@v`ze44rtIZ>A{A)r>dUAn4T|?8XMcX$W5i zB% 2tpd%<#=j&=Jz5afio9P4pO8BlbH+8TG_5CKWu{&uSsvY z^?PyMJgxy4np2104XkKGx(SR@fLQVP@s|1DWm%pq2jxwpkcf4ZtKGLyIuu2dL9| zwgHy%)*s-sN^t1f$m$=|ELdNNS>^N(*q UAol^yD-Ci^qDeFSlF6e?{!@) -B?D*3NJ{(jAf;jI<67!S z3BG=S%=x$Q5$AbH|2q0_F)o#ngOJ0dHkzI=%12^({7Aa3_Kl733?X?F5-)TAgZY~A zL_gy3jv4aj|A;Xu6ypMzI)EX%VUqScTHs((*6;?6Y Mmh5#)u<2b6N!GD1&O`wXB!Xfc8?!QpVsao<#XnQh39{8{5HJv*U zJ&q=DU+`8$O$3?qA{=;VX%{BA_H38~er|x(H|V=aSaWBmo) _d8y`X?^q0ew)SeI9;sFQzDFeS^%Hc}U zu!FyfX;TRjt=DJ%7W%AYHi`HRQlZ~u_+2gEbB#9B)Lg>CwYP7cjpwcLQAkpbCNR{ zjw{($3Kh`md=s`TY!!ErRUHx}K^|)rSM)(CFAr^q%+^x l}3+|R{kfEpG*Tg{t5ev3F8~Yh3>aQ zzWcF<_~a+X4Y7m 7 G zeGbg_GtmHUP$%3X2A9(5szJC 8jz%M|RU#Eqqz}0P*Ib7uF2xFQZ2*Vc-b4{c%Fxn#YU%{qXNFWns zNu=Q*(dwlsWJ9FE=IEAC<%sBKDMIu=587$l1-J;4Qg|>6eM{Sdlnz>x0?4Z};D$SR zS8@f=yFFkYdK<}sJvdH}A3^B{p1YtEMSN^=&*BaSF8bseM#z&L47hNbf#{CLxOA$E ztROa%A=x@(UCc`vVIo)zyxK8kU`0nG(xy)D +4|X6*xYG6YEKTKHbgeHvv3N0bdTBd>S^v6=y28-wM%gEa;j6 zH6NIk$3r8*FxqH5{O6s+w7cBcSf<)(xi&T0z*U$eAoj?oqK)zPY7jO*DoY-XHt=>G za?uJern_-}EK>vc95)yJgpXOh+|u2EzqG ^M|G!u)%X=oLg99{t0Y;5Tui~AS@Q;1L)V|-c7 z36wV3-M7Er1ChQ!I8eK4CdC7l@AeZYlQAT<_KEP=|yLu9jKjUfq;xAh+92w$Fg zsUU|j6}l1T&OydaRLW}fp;!aENGQc(BNnO}WwU5|9Q^r5w7n=E%MGIW3n9ymHuOP| zg?%9viQcqC-_zH?vzn^$vbDc)tz6a5$mAG$ndDGE f`mk^ z+lajqjjUuBSc;y#)E{Y;6~DB`90w)xK?P?dY@Dwtn V3F B(b(zu-n9HJuTnGe44lx$lrstvivYpBcXLp1$E+MWZ z+o_%d&H2T3gmnM!@Z87%=;zs3=#cJ@#pCN}OG_RWI`s8SlEPp|bUTTW{GV)#x 8JL!HW>X<8Zr+Cg5p}8uOrlWus>sV4EO+Hj{7U^i;zUX%!qS*QXjW(wk~Tv^8g4 zDpVH6xdx|2=)DPm^Uniq@ZQ8U%I2lvG N%d4Hb%d*D@ z8?tVgap`2R5>6hO(!;!ai{si!l$WtgquNL;SrUbm49H9zqpJE%#@YE_q)%RRSo8|t z3;6+=a`$lKR+1@gb|zD&_j>+Vr>A(?afC5YZH1cSaM5OS$ v-5Hfp$jwbf!klzt5Q#f)UWI%t-RP6a2`j>bw2q=GDD0^)Ngm4^Ol14= zx??Z+Dw%-gv2>+kP%W}G9U{1l_E1{J__E-u^y7 <+~;Jb^65yF3_A<*jv@3rT< zm(sBTg#K}NoFZ3@GscaMr!}@|38+7+R;I7eHwN*hCE#5SJA+;DMgA680{%M=mH;km zS^{39JQz5>c4c2S%bxk%EDK9O@p$xzMis9f7N{I=`~#0|kbDGbW;$US0fMx;mq(`? zS@OYn(M5L8GA_$4qdE4Ck$e-)j{wJuXfXD_6li#Lsco|F5I*rK8#C}~w=$+}e8DuX z;ZpP!S;i1*cbf&|BLX(T8QezS7{r_H{(!=0Q+Iq-Tjh6Is@*W9C_lac b69`}I{Is{Tw8gVdthedJTppvNa59z+6v#(l(_{m$s8axo z*N(ZM33N< 8rXN~ahC{cQJ(DRu+8O+ZmESy< z?Ld5A>@{dak-fuY^;O2rs-j*!_z;FLD|*H%Xjb$q!c Kt2qjWN!KalMhX>b6_=_*aiL|id*E0tBvIr+4&y3%cmPt zxTIxDWsu~{N~Tn-e7L&0G`VTIfydVY#uPa>*pX6ClSSq=SdgIO>b3ha (K+~Ew9yEVH6LkQH^-cZxXQ;;s1VQilyxtGGT^MprC7GW#x=^(2pQD zL0`}g`uh|Q=pKASq3=g{cL;QGZ3y(@YuiC5+{kV_9N$pfpQLy|Pr)}7dL_b-g+On< z7A6r8l Ez-;a{XS+A+Y$uG<{bYa!K9^IO;L^iGrkCt!FGUB-; z&8f|^FliE)izFTC?=VNnzh7rO)s{+gX5hs?8TZ-Z%)avdmI} gMRIG9+bnlP1%m}&xUyJx+L;yh3l=u6uf+1N8w|2x$VxQz}aX+Jh$d#GurdI8u( zK^D(39 bMI2{bV{aGMxrB`a{m10$EFE@qVp`9?uB7Xj5^#TsSh zs?Ilt#MQ)M!YMO+FOG%{_!O=|#|b|hF0!BkZqWQ>zTwj|#{?cIFv4x6U;xxM$~W&a z inuv~SP}p4^9;5|F6U0{&Hc}fCLh?#8FN6>0 zmCTHOcP0b{PN~qkiQ_X_k?+ep(iRevhP2A DBUI^Nnnx;D`mrXxjz+ZZ&%Mv`R6#ED}`9wYM5Qv#lCA4Wy&6JCr;z zza!5@)R}sjd^pqSDLWNGsAi)#v*AWCvdFkDN-;)9-+ByShjXBmMVK4P29m}zNQ&DK zs3Yy<#neFYZN@H}eKHE~Yv~e5zr%RR7G5$2L#kAMU2NP)DSo8L+&iJqfuQx8)-SN) zP6HQ}T1nM+8P5=Z`wPHKnRi3gqd;EAY`N}kBP}zQR%tNqI3Ju;MV5i=_&saL7Wocl zWJ=42VW(M$!Q~NJMX6@{F?-m$H3Wbrg{XzlUl{ r9~PzE@Rt%!IOXcPlBb8W~x zb0JXFs9og)Wz|CCif)+zA0qVdU;b;MkwhCFSjVfbr0&cCFI#IXE(0OTU#$O|eo+oB zTn2))8SJPZfS(GS1=_>;e}hF#iafC&Tfn%I3sRX7eB~Y^Nf8h)86CX`mK|yz$;O05 zYS!gI@gn1W^MG$xU*V YfKvf5Z?TCxisXx1uJrc z<+!T?j-x&mZ|C^@s@z~cu129aPnG=YUSnvA3B;F!3P?>{0-z3@W!zD7VJfvMN?&YD zN bE}TXjN& z4KBECQbjDPB%|a;#GfeEV9W?FdO0d&`x01aWnFzAB)67I%A7~rT>u2v@FF^cejFB| zb(((a8>h*v#bLojSc1q>)Re0QopchJZ)MOo+n$3nP!YCn0f&wl&^Mpp(KYkA&bGX# z;gZ&jFy{k2{w^#HBg*?dkl-G;?;bBw?z`SMwAJ*V=0M6=0Kha4GVr1>M)r7dS?zL+ zE{el*@R-xXinib!-!jC}nZ7+^74ja`!Fj4E58^Y-_{y+gYAISpi5`qB&V&)S1ORB$ z4&nr@?CM*9TN@g46y|Odn0uK*92{B; QDLF1I87plt`UF7glF|82YkM zt8JR93Mq?p-Z|V^^?E!EaaviLOewL14GPf{2z1{Eo #uInP6v7Oq}&5TDc5yeBO z?wgxT3I;|jMrIOR$6tHcaP%Y~3xH&tE%MZJ7 TE z_f|M*QQEC=(z-H?=V|;`wyntC%Al!*X+c~h6GI39rr({T)zY^#MU&N~#*k79(9>!3 zotNSa?t;AI|4>@!@VpMo_3 p zHbBO(g2#bw6pq*r^jl_TWHCB_YQpwU8%QME)dhL ez32(#`+ zD}K9*|CMmTtWrf^w!#@Qbvb;7fPi1?g(rgGB)9cREbqvSa}!R-FE=Jl(0~vvlKzs& zT;1Z{3IlvfSuGu(W`b?)R67?_&oq#>3r_Y}Txyn+%>Xi;I@XQ*X4Xe(>HI%oN=6p` zXCa=h^`jj%_a*SMqkQV1F?(gxEyNrL*Kh|mP&q%GuopjK^h@H}!Lszt16E8r_-WEN zf6ioLh?WA6{QD!uwFy=$at5NGsL%hJ?%RbUnt^}7C{C4P-#m4N(Sz=HZ6TpSUpk*b zOWrF?EL^#IFc6MC5WBXWfV<7H1kAP%fWsrj i;_o4dVsdhb%a1d#zGAvBk z;7972K$(zDm@d4W2}zJgRu~287W(JV3EW!^s<&EJ|Bm3k(^j4b(`E5WYyen=zd(YE zy85b7a>q*e8~{?iHs*49YNatG9URf383OC0(rxF#m1MoRA75}5-jB)vS3k7v-xLq) z#X=SD*S;7gr$1^q+N#)$Dx6e>GfXaf)c6No7geu4e>tC|dzDIq|IY3=`42ol_py-D z^H5qN-t*&`g~yC(sTSr}Cz!lvdf_thcXq1X` %zQGc0*iMJG>n!x6^1*O$nC;a zkd1blU$nbAL7~+_ayopuv%=`njRU5yA!YVd%C9Sop<}J8ZK&FY0`Qk1E@cOJH|AX$ z eHRj}+b=J6xQ zq E&bZmL+6R4#TgN$xIf(@znSO ^O7g2toN zJy#B`H2Qb5%8Uec8RK>GmP#Xu*7ja^wS24+%PW@sP*_e?8vS}=h}YA69tYhFDlgxv z>?xA{y~bgCJWLy;*_`znlcEi*!^ruf9Bl~0fB4PH={{ppteMNV9%yQ?L!JGF`Lv!U zk*z-1jxx!HgGuCw59S>TwAm!e{%kzh7K445A#zxiaaCKr%c_h?-3UQ;6vL(>{l`hH zR1m;S6Y#d||b*rj-7>6S8UF^mY30O3>RB1<;!9TU+CqhwH5ypB%U*q%oH@vwF7@ zl=XQy{zn^_oCX~f{Z{Wlo2lP84yr%Lm8rN!VH@0-sYL`$1mylF;o1gntan55HI6i) zW3knkuGOyHV@{9nsj29#x54e{&fw+rB>_{Xc9Gsdm;qKUt)&CJqg1+A>l@~C #+3q4=_3!V`23#BZkf9OtayrK&)Sb}oY-9eAF?6L6SP#pBI7CE?amI1@+j_To!( z;q|?I>jCI#iU-^F#1|E`Xm`fSJx^hyhG_{IVC_gWjMQp54r83+8dhi-)Or^tq6U+; zWEjU2; p4mg3ROMf4?F z)P%|0XJA1n>j$(o_(DBn44O^tabZYevHT%<2~2y_PR{idn^13-BPag?V7 zdR`f9gDC5F<1!SyB-P!LBjyN@_BBRU9OJxF5ooK4&JcV#zNNn`Tm%1I4%Fk~L{M=J z5}!mZ_o&)&A!w@Hu?9GsndJF3#&x9))SCAc9d9?mo Qaq^gvc(D8 zl?vyk_?gZi?Lyyz|0sjoBh*VoH&PlH;8O+5t93^i+^#c=;=$5~Rs1MC@1rWYni49V z8GC^)0M*mYTQoSPP<(|m<1H)x#Z)PtH8MulP$sjjBamqbGTcqnq^l)r(tX3BD7L3$ z@w3JarIuj%sJ|)i#621C9nF9MdDo#i4>yKs=` $QiP1(tp~O zr*BEZC|_x*#_kywAC&&Z=rO>;etlf4r28M+HPKfcS9Qs Q7ukvDJ3m uLt?J+JUzqVyt~X}Tt{$%A?JG~OhZ+lo6K{dX%ed!^3|g@JF^S*u zoG~_>B|@cK`<&4;j{eU_V~-1Ca9Ctm#X2 E$pz5RjcDk;_`PeeWIXk# z(hU5CXebpv5upn2Md9Njktsz6gxn3rKr+7i^Fta267rEu@>PcBfIar#`!JjFa?=Lm zW#TvZagCI7o+lRtkcMze-Dv#E0a7JKzJT>8SlCY+s0Uv#`V~^8?Omss4B#F5lIs-b z!60!MI CcOkx)@bL}UguG&3tTUzsxu_()B?T2|HtMbpB2ok?xi zwR^>sEUhH1j$G@C`KoI-F)Qm@R~{;vm6nx~5B}e^&pE^BwcoGbpU;QSoVEAbd$0Xk zd+oK?Ui-z@tV_DkccG;)v#_-$b8gOpT8QvgvB9rf<4D4)@s_cI*P$E+m#h?Ov)QWG ztx+)?ctNu!cy2eG8e#v)6Ncxq-LFG?%n^RdX7V;17**2}@TQrK+h*NQF(j6-gi7lh z+6vOEH#=Tw9UjeTzpGQgjs5q|rv