diff --git a/go_backend/extension_fallback_helpers.go b/go_backend/extension_fallback_helpers.go index 5a854c95..fa8d4798 100644 --- a/go_backend/extension_fallback_helpers.go +++ b/go_backend/extension_fallback_helpers.go @@ -381,7 +381,21 @@ func fallbackRuntimeHealthStatus(ext *loadedExtension) string { return "unknown" } - status := strings.ToLower(strings.TrimSpace(CheckExtensionHealthCached(ext).Status)) + health, cached := PeekExtensionHealthCached(ext) + if !cached { + staleHealth, hasStale := peekExtensionHealthStale(ext) + RefreshExtensionHealthAsync(ext) + if !hasStale { + return "unknown" + } + // An expired offline verdict must not keep suppressing a recovered + // provider while its refresh runs in the background. + if strings.EqualFold(staleHealth.Status, "offline") { + return "unknown" + } + health = staleHealth + } + status := strings.ToLower(strings.TrimSpace(health.Status)) switch status { case "online", "degraded", "offline": return status diff --git a/go_backend/extension_health.go b/go_backend/extension_health.go index 5543fce5..100249f1 100644 --- a/go_backend/extension_health.go +++ b/go_backend/extension_health.go @@ -48,16 +48,89 @@ type cachedExtensionHealthResult struct { } var ( - extensionHealthCacheMu sync.Mutex - extensionHealthCache = map[string]cachedExtensionHealthResult{} + extensionHealthCacheMu sync.Mutex + extensionHealthCache = map[string]cachedExtensionHealthResult{} + extensionHealthRefresh = map[string]struct{}{} + extensionHealthGeneration uint64 ) func clearExtensionHealthCache() { extensionHealthCacheMu.Lock() extensionHealthCache = map[string]cachedExtensionHealthResult{} + extensionHealthRefresh = map[string]struct{}{} + extensionHealthGeneration++ extensionHealthCacheMu.Unlock() } +// PeekExtensionHealthCached returns only an already-computed, unexpired +// health snapshot. It never performs network I/O. Download provider ordering +// uses this path so an informational health endpoint can never delay the real +// provider attempt. +func PeekExtensionHealthCached(ext *loadedExtension) (ExtensionHealthResult, bool) { + if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 { + return ExtensionHealthResult{}, false + } + cacheKey := strings.TrimSpace(ext.ID) + if cacheKey == "" { + return ExtensionHealthResult{}, false + } + + now := time.Now() + extensionHealthCacheMu.Lock() + defer extensionHealthCacheMu.Unlock() + cached, ok := extensionHealthCache[cacheKey] + if !ok || !now.Before(cached.expiresAt) { + return ExtensionHealthResult{}, false + } + return cached.result, true +} + +func peekExtensionHealthStale(ext *loadedExtension) (ExtensionHealthResult, bool) { + if ext == nil { + return ExtensionHealthResult{}, false + } + cacheKey := strings.TrimSpace(ext.ID) + if cacheKey == "" { + return ExtensionHealthResult{}, false + } + extensionHealthCacheMu.Lock() + defer extensionHealthCacheMu.Unlock() + cached, ok := extensionHealthCache[cacheKey] + return cached.result, ok +} + +// RefreshExtensionHealthAsync coalesces background refreshes per extension. +// Callers deliberately do not wait: stale or missing health data must not be +// on the latency-critical download path. +func RefreshExtensionHealthAsync(ext *loadedExtension) { + if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 { + return + } + cacheKey := strings.TrimSpace(ext.ID) + if cacheKey == "" { + return + } + + extensionHealthCacheMu.Lock() + if _, refreshing := extensionHealthRefresh[cacheKey]; refreshing { + extensionHealthCacheMu.Unlock() + return + } + extensionHealthRefresh[cacheKey] = struct{}{} + generation := extensionHealthGeneration + extensionHealthCacheMu.Unlock() + + go func() { + result := CheckExtensionHealth(ext) + extensionHealthCacheMu.Lock() + if generation == extensionHealthGeneration { + cacheExtensionHealthResultLocked(ext, result) + delete(extensionHealthRefresh, cacheKey) + } + extensionHealthCacheMu.Unlock() + }() +} + func CheckExtensionHealthJSON(extensionID string) (string, error) { manager := getExtensionManager() ext, err := manager.GetExtension(extensionID) @@ -84,14 +157,9 @@ func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult { return CheckExtensionHealth(ext) } - now := time.Now() - extensionHealthCacheMu.Lock() - cached, ok := extensionHealthCache[cacheKey] - if ok && now.Before(cached.expiresAt) { - extensionHealthCacheMu.Unlock() - return cached.result + if cached, ok := PeekExtensionHealthCached(ext); ok { + return cached } - extensionHealthCacheMu.Unlock() result := CheckExtensionHealth(ext) cacheExtensionHealthResult(ext, result) @@ -108,17 +176,26 @@ func cacheExtensionHealthResult(ext *loadedExtension, result ExtensionHealthResu return } + extensionHealthCacheMu.Lock() + cacheExtensionHealthResultLocked(ext, result) + extensionHealthCacheMu.Unlock() +} + +// cacheExtensionHealthResultLocked stores a result while the caller owns +// extensionHealthCacheMu. Keeping this small helper avoids a clear-vs-refresh +// race without taking the same mutex recursively. +func cacheExtensionHealthResultLocked(ext *loadedExtension, result ExtensionHealthResult) { + cacheKey := strings.TrimSpace(ext.ID) + ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth) if result.Status == "unknown" && ttl > extensionHealthUnknownCache { ttl = extensionHealthUnknownCache } - extensionHealthCacheMu.Lock() extensionHealthCache[cacheKey] = cachedExtensionHealthResult{ result: result, expiresAt: time.Now().Add(ttl), } - extensionHealthCacheMu.Unlock() } func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult { diff --git a/go_backend/extension_health_misc_supplement_test.go b/go_backend/extension_health_misc_supplement_test.go index 0faf6d7d..366a65f1 100644 --- a/go_backend/extension_health_misc_supplement_test.go +++ b/go_backend/extension_health_misc_supplement_test.go @@ -6,6 +6,7 @@ import ( "net" "strings" "testing" + "time" ) func TestExtensionHealthClassificationAndValidation(t *testing.T) { @@ -74,3 +75,39 @@ func TestCoverHelpersRejectEmptyURL(t *testing.T) { t.Fatalf("expected empty cover error") } } + +func TestPeekExtensionHealthCachedNeverRefreshesSynchronously(t *testing.T) { + clearExtensionHealthCache() + ext := &loadedExtension{ + ID: "cached-health-ext", + Manifest: &ExtensionManifest{ServiceHealth: []ExtensionHealthCheck{{ + ID: "main", URL: "https://status.example.com", + }}}, + } + if _, ok := PeekExtensionHealthCached(ext); ok { + t.Fatal("unexpected cache hit") + } + + want := ExtensionHealthResult{ExtensionID: ext.ID, Status: "degraded"} + extensionHealthCacheMu.Lock() + extensionHealthCache[ext.ID] = cachedExtensionHealthResult{ + result: want, expiresAt: time.Now().Add(time.Minute), + } + extensionHealthCacheMu.Unlock() + got, ok := PeekExtensionHealthCached(ext) + if !ok || got.Status != want.Status { + t.Fatalf("cached health = %#v/%v", got, ok) + } + + extensionHealthCacheMu.Lock() + entry := extensionHealthCache[ext.ID] + entry.expiresAt = time.Now().Add(-time.Second) + extensionHealthCache[ext.ID] = entry + extensionHealthCacheMu.Unlock() + if _, ok := PeekExtensionHealthCached(ext); ok { + t.Fatal("expired cache entry was returned") + } + if stale, ok := peekExtensionHealthStale(ext); !ok || stale.Status != want.Status { + t.Fatalf("stale health snapshot = %#v/%v", stale, ok) + } +} diff --git a/go_backend/extension_providers_test.go b/go_backend/extension_providers_test.go index dea84fdb..2fcdc8a7 100644 --- a/go_backend/extension_providers_test.go +++ b/go_backend/extension_providers_test.go @@ -182,11 +182,20 @@ func TestPrioritizeFallbackProvidersByHealthPrefersOnlineAndSkipsOffline(t *test manager.mu.Unlock() extensionHealthCacheMu.Lock() + delete(extensionHealthCache, amazon.ID) delete(extensionHealthCache, deezer.ID) extensionHealthCacheMu.Unlock() }() extensionHealthCacheMu.Lock() + extensionHealthCache[amazon.ID] = cachedExtensionHealthResult{ + result: ExtensionHealthResult{ + ExtensionID: amazon.ID, + Status: "offline", + CheckedAt: time.Now().UTC().Format(time.RFC3339), + }, + expiresAt: time.Now().Add(time.Minute), + } extensionHealthCache[deezer.ID] = cachedExtensionHealthResult{ result: ExtensionHealthResult{ ExtensionID: deezer.ID,