fix health checks on startup

This commit is contained in:
Evan Jarrett
2025-10-20 12:22:25 -05:00
parent b155534d1b
commit 4ca90fc3af
8 changed files with 1087 additions and 608 deletions
+15
View File
@@ -71,6 +71,21 @@ ATCR_UI_ENABLED=true
# Log formatter: text, json (default: text)
# ATCR_LOG_FORMATTER=text
# ==============================================================================
# Hold Health Check Configuration
# ==============================================================================
# How often to check health of hold endpoints in the background (default: 15m)
# Queries database for unique hold endpoints and checks if they're reachable
# Examples: 5m, 15m, 30m, 1h
# ATCR_HEALTH_CHECK_INTERVAL=15m
# How long to cache health check results (default: 15m)
# Cached results avoid redundant health checks on page renders
# Should be >= ATCR_HEALTH_CHECK_INTERVAL for efficiency
# Examples: 15m, 30m, 1h
# ATCR_HEALTH_CACHE_TTL=15m
# ==============================================================================
# Jetstream Configuration (ATProto event streaming)
# ==============================================================================
+24 -4
View File
@@ -75,19 +75,39 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
// Initialize hold health checker
fmt.Println("Initializing hold health checker...")
cacheTTL := 15 * time.Minute // Cache TTL from user requirements
// Parse health check cache TTL from environment (default: 15m)
cacheTTL := 15 * time.Minute
if cacheTTLStr := os.Getenv("ATCR_HEALTH_CACHE_TTL"); cacheTTLStr != "" {
if parsed, err := time.ParseDuration(cacheTTLStr); err == nil {
cacheTTL = parsed
} else {
fmt.Printf("Warning: Invalid ATCR_HEALTH_CACHE_TTL '%s', using default 15m\n", cacheTTLStr)
}
}
healthChecker := holdhealth.NewChecker(cacheTTL)
// Start background health check worker
refreshInterval := 5 * time.Minute // Refresh every 5 minutes
// Parse refresh interval from environment (default: 15m)
refreshInterval := 15 * time.Minute
if refreshIntervalStr := os.Getenv("ATCR_HEALTH_CHECK_INTERVAL"); refreshIntervalStr != "" {
if parsed, err := time.ParseDuration(refreshIntervalStr); err == nil {
refreshInterval = parsed
} else {
fmt.Printf("Warning: Invalid ATCR_HEALTH_CHECK_INTERVAL '%s', using default 15m\n", refreshIntervalStr)
}
}
startupDelay := 5 * time.Second // Wait for hold services to start (Docker compose)
dbAdapter := holdhealth.NewDBAdapter(uiDatabase)
healthWorker := holdhealth.NewWorker(healthChecker, dbAdapter, refreshInterval)
healthWorker := holdhealth.NewWorkerWithStartupDelay(healthChecker, dbAdapter, refreshInterval, startupDelay)
// Create context for worker lifecycle management
workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel() // Ensure context is cancelled on all exit paths
healthWorker.Start(workerCtx)
fmt.Println("Hold health worker started (5min refresh interval, 15min cache TTL)")
fmt.Printf("Hold health worker started (5s startup delay, %s refresh interval, %s cache TTL)\n", refreshInterval, cacheTTL)
// Initialize OAuth components
fmt.Println("Initializing OAuth components...")
+827 -603
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -309,6 +309,49 @@ func GetUserRepositories(db *sql.DB, did string) ([]Repository, error) {
return repos, nil
}
// GetRepositoryMetadata retrieves metadata for a repository from its most recent manifest
func GetRepositoryMetadata(db *sql.DB, did string, repository string) (title, description, sourceURL, documentationURL, licenses, iconURL string, err error) {
var titleNull, descriptionNull, sourceURLNull, documentationURLNull, licensesNull, iconURLNull sql.NullString
err = db.QueryRow(`
SELECT title, description, source_url, documentation_url, licenses, icon_url
FROM manifests
WHERE did = ? AND repository = ?
ORDER BY created_at DESC
LIMIT 1
`, did, repository).Scan(&titleNull, &descriptionNull, &sourceURLNull, &documentationURLNull, &licensesNull, &iconURLNull)
if err == sql.ErrNoRows {
// No manifests found - return empty strings
return "", "", "", "", "", "", nil
}
if err != nil {
return "", "", "", "", "", "", err
}
// Convert NullString to string
if titleNull.Valid {
title = titleNull.String
}
if descriptionNull.Valid {
description = descriptionNull.String
}
if sourceURLNull.Valid {
sourceURL = sourceURLNull.String
}
if documentationURLNull.Valid {
documentationURL = documentationURLNull.String
}
if licensesNull.Valid {
licenses = licensesNull.String
}
if iconURLNull.Valid {
iconURL = iconURLNull.String
}
return title, description, sourceURL, documentationURL, licenses, iconURL, nil
}
// GetUserByDID retrieves a user by DID
func GetUserByDID(db *sql.DB, did string) (*User, error) {
var user User
+120
View File
@@ -0,0 +1,120 @@
package db
import (
"testing"
"time"
)
func TestGetRepositoryMetadata(t *testing.T) {
// Create in-memory test database
db, err := InitDB(":memory:")
if err != nil {
t.Fatalf("Failed to init database: %v", err)
}
defer db.Close()
// Insert test user
testUser := &User{
DID: "did:plc:test123",
Handle: "testuser.bsky.social",
PDSEndpoint: "https://test.pds.example.com",
Avatar: "",
LastSeen: time.Now(),
}
if err := UpsertUser(db, testUser); err != nil {
t.Fatalf("Failed to insert user: %v", err)
}
// Test 1: No manifests - should return empty strings
title, description, sourceURL, documentationURL, licenses, iconURL, err := GetRepositoryMetadata(db, testUser.DID, "nonexistent")
if err != nil {
t.Fatalf("Expected no error for nonexistent repo, got: %v", err)
}
if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" {
t.Error("Expected all empty strings for nonexistent repository")
}
// Test 2: Insert manifest with metadata
_, err = db.Exec(`
INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at,
title, description, source_url, documentation_url, licenses, icon_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json",
time.Now().Add(-2*time.Hour),
"My App", "A cool application", "https://github.com/user/myapp", "https://docs.example.com", "MIT", "https://example.com/icon.png")
if err != nil {
t.Fatalf("Failed to insert manifest: %v", err)
}
// Test 3: Retrieve metadata
title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp")
if err != nil {
t.Fatalf("Failed to get repository metadata: %v", err)
}
if title != "My App" {
t.Errorf("Expected title 'My App', got '%s'", title)
}
if description != "A cool application" {
t.Errorf("Expected description 'A cool application', got '%s'", description)
}
if sourceURL != "https://github.com/user/myapp" {
t.Errorf("Expected sourceURL 'https://github.com/user/myapp', got '%s'", sourceURL)
}
if documentationURL != "https://docs.example.com" {
t.Errorf("Expected documentationURL 'https://docs.example.com', got '%s'", documentationURL)
}
if licenses != "MIT" {
t.Errorf("Expected licenses 'MIT', got '%s'", licenses)
}
if iconURL != "https://example.com/icon.png" {
t.Errorf("Expected iconURL 'https://example.com/icon.png', got '%s'", iconURL)
}
// Test 4: Insert newer manifest with different metadata
_, err = db.Exec(`
INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at,
title, description, source_url, documentation_url, licenses, icon_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, testUser.DID, "myapp", "sha256:def456", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json",
time.Now(), // Most recent
"My App v2", "An even cooler application", "https://github.com/user/myapp-v2", "https://v2.docs.example.com", "Apache-2.0", "https://example.com/icon-v2.png")
if err != nil {
t.Fatalf("Failed to insert newer manifest: %v", err)
}
// Test 5: Should return metadata from most recent manifest
title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp")
if err != nil {
t.Fatalf("Failed to get repository metadata: %v", err)
}
if title != "My App v2" {
t.Errorf("Expected title from newest manifest 'My App v2', got '%s'", title)
}
if description != "An even cooler application" {
t.Errorf("Expected description from newest manifest, got '%s'", description)
}
if licenses != "Apache-2.0" {
t.Errorf("Expected licenses 'Apache-2.0', got '%s'", licenses)
}
// Test 6: Manifest with NULL metadata fields
_, err = db.Exec(`
INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, testUser.DID, "minimal-app", "sha256:minimal", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json", time.Now())
if err != nil {
t.Fatalf("Failed to insert minimal manifest: %v", err)
}
// Test 7: Should handle NULL fields gracefully
title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "minimal-app")
if err != nil {
t.Fatalf("Failed to get repository metadata for minimal app: %v", err)
}
if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" {
t.Error("Expected all empty strings for manifest with NULL metadata fields")
}
}
+14
View File
@@ -134,6 +134,20 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
ManifestCount: len(manifests),
}
// Fetch repository metadata from most recent manifest
title, description, sourceURL, documentationURL, licenses, iconURL, err := db.GetRepositoryMetadata(h.DB, owner.DID, repository)
if err != nil {
log.Printf("Failed to fetch repository metadata: %v", err)
// Continue without metadata on error
} else {
repo.Title = title
repo.Description = description
repo.SourceURL = sourceURL
repo.DocumentationURL = documentationURL
repo.Licenses = licenses
repo.IconURL = iconURL
}
// Fetch star count
stats, err := db.GetRepositoryStats(h.DB, owner.DID, repository)
if err != nil {
+17
View File
@@ -251,3 +251,20 @@ func TestGetCacheStats(t *testing.T) {
t.Errorf("Expected unreachable=1, got %v", stats["unreachable"])
}
}
func TestNewWorkerWithStartupDelay(t *testing.T) {
checker := NewChecker(15 * time.Minute)
// Test NewWorker (no delay)
worker := NewWorker(checker, nil, 5*time.Minute)
if worker.startupDelay != 0 {
t.Errorf("Expected startupDelay=0 for NewWorker, got %v", worker.startupDelay)
}
// Test NewWorkerWithStartupDelay
startupDelay := 5 * time.Second
workerWithDelay := NewWorkerWithStartupDelay(checker, nil, 5*time.Minute, startupDelay)
if workerWithDelay.startupDelay != startupDelay {
t.Errorf("Expected startupDelay=%v, got %v", startupDelay, workerWithDelay.startupDelay)
}
}
+27 -1
View File
@@ -22,6 +22,7 @@ type Worker struct {
cleanupTicker *time.Ticker
stopChan chan struct{}
wg sync.WaitGroup
startupDelay time.Duration
}
// NewWorker creates a new background worker
@@ -32,6 +33,19 @@ func NewWorker(checker *Checker, db DBQuerier, refreshInterval time.Duration) *W
refreshTicker: time.NewTicker(refreshInterval),
cleanupTicker: time.NewTicker(30 * time.Minute), // Cleanup every 30 minutes
stopChan: make(chan struct{}),
startupDelay: 0, // No delay by default for backward compatibility
}
}
// NewWorkerWithStartupDelay creates a new background worker with a startup delay
func NewWorkerWithStartupDelay(checker *Checker, db DBQuerier, refreshInterval, startupDelay time.Duration) *Worker {
return &Worker{
checker: checker,
db: db,
refreshTicker: time.NewTicker(refreshInterval),
cleanupTicker: time.NewTicker(30 * time.Minute), // Cleanup every 30 minutes
stopChan: make(chan struct{}),
startupDelay: startupDelay,
}
}
@@ -43,7 +57,19 @@ func (w *Worker) Start(ctx context.Context) {
log.Println("Hold health worker: Starting background health checks")
// Perform initial check immediately
// Wait for services to be ready (Docker startup race condition)
if w.startupDelay > 0 {
log.Printf("Hold health worker: Waiting %s for services to be ready...", w.startupDelay)
select {
case <-time.After(w.startupDelay):
// Continue with initial check
case <-ctx.Done():
log.Println("Hold health worker: Context cancelled during startup delay")
return
}
}
// Perform initial check
w.refreshAllHolds(ctx)
for {