diff --git a/.github/workflows/dev-image.yml b/.github/workflows/dev-image.yml index fc0a0a3ba..11cb2d8cb 100644 --- a/.github/workflows/dev-image.yml +++ b/.github/workflows/dev-image.yml @@ -172,21 +172,32 @@ jobs: id: normalize run: echo "image_name=$(echo ${{ env.IMAGE_NAME }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + - name: Get current date + id: date + run: echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT + - name: Create and push manifest run: | IMAGE=${{ env.REGISTRY }}/${{ steps.normalize.outputs.image_name }} AMD64_IMAGE="${IMAGE}:dev-amd64-temp" ARM64_IMAGE="${IMAGE}:dev-arm64-temp" + DATE=${{ steps.date.outputs.date }} echo "AMD64 Image: $AMD64_IMAGE" echo "ARM64 Image: $ARM64_IMAGE" + echo "Creating multi-arch manifest: ${IMAGE}:dev" - docker manifest create ${IMAGE}:dev \ $AMD64_IMAGE \ $ARM64_IMAGE docker manifest push ${IMAGE}:dev + echo "Creating multi-arch manifest: ${IMAGE}:dev-${DATE}" + docker manifest create ${IMAGE}:dev-${DATE} \ + $AMD64_IMAGE \ + $ARM64_IMAGE + docker manifest push ${IMAGE}:dev-${DATE} + - name: Clean up temporary tags run: | IMAGE=${{ env.REGISTRY }}/${{ steps.normalize.outputs.image_name }} diff --git a/.github/workflows/manual-build.yml b/.github/workflows/manual-build.yml index 93fd6cf7e..0920999bc 100644 --- a/.github/workflows/manual-build.yml +++ b/.github/workflows/manual-build.yml @@ -244,19 +244,26 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Get current date + id: date + run: echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT + # Create sanitized branch name for tag - name: Create sanitized tag id: tag run: | # Replace / with - and remove other special characters SAFE_BRANCH=$(echo "${{ inputs.branch }}" | sed 's/\//-/g' | sed 's/[^a-zA-Z0-9._-]/-/g') + DATE=${{ steps.date.outputs.date }} TAG="${SAFE_BRANCH}-${{ inputs.tag_suffix }}" + DATED_TAG="${TAG}-${DATE}" AMD64_TAG="${TAG}-amd64" ARM64_TAG="${TAG}-arm64" echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "dated_tag=$DATED_TAG" >> $GITHUB_OUTPUT echo "amd64_tag=$AMD64_TAG" >> $GITHUB_OUTPUT echo "arm64_tag=$ARM64_TAG" >> $GITHUB_OUTPUT - echo "Creating multi-arch manifest with tag: $TAG" + echo "Creating multi-arch manifest with tags: $TAG, $DATED_TAG" # Create and push multi-platform manifest - name: Create multi-platform manifest @@ -264,21 +271,28 @@ jobs: AMD64_IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:${{ steps.tag.outputs.amd64_tag }}" ARM64_IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:${{ steps.tag.outputs.arm64_tag }}" MANIFEST_TAG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:${{ steps.tag.outputs.tag }}" + DATED_MANIFEST_TAG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:${{ steps.tag.outputs.dated_tag }}" echo "AMD64 Image: $AMD64_IMAGE" echo "ARM64 Image: $ARM64_IMAGE" echo "Manifest Tag: $MANIFEST_TAG" + echo "Dated Manifest Tag: $DATED_MANIFEST_TAG" # Create and push multi-arch manifest docker manifest create $MANIFEST_TAG $AMD64_IMAGE $ARM64_IMAGE docker manifest push $MANIFEST_TAG + # Create and push dated multi-arch manifest + docker manifest create $DATED_MANIFEST_TAG $AMD64_IMAGE $ARM64_IMAGE + docker manifest push $DATED_MANIFEST_TAG + # Output manifest details - name: Manifest created successfully run: | echo "✅ Multi-architecture Docker manifest created successfully!" echo "" echo "📦 Multi-arch image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:${{ steps.tag.outputs.tag }}" + echo "📦 Dated multi-arch image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:${{ steps.tag.outputs.dated_tag }}" echo "📦 AMD64 image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:${{ steps.tag.outputs.amd64_tag }}" echo "📦 ARM64 image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:${{ steps.tag.outputs.arm64_tag }}" echo "" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19c3e0317..0a9224275 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -457,12 +457,17 @@ jobs: echo "major=$MAJOR" >> $GITHUB_OUTPUT echo "minor=$MINOR" >> $GITHUB_OUTPUT + - name: Get current date + id: date + run: echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT + # Create and push multi-platform manifests using temporary tags - name: Create multi-platform manifest run: | # Use temporary tagged images instead of digests AMD64_IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v${{ steps.version.outputs.version }}-amd64-temp" ARM64_IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v${{ steps.version.outputs.version }}-arm64-temp" + DATE=${{ steps.date.outputs.date }} echo "AMD64 Image: $AMD64_IMAGE" echo "ARM64 Image: $ARM64_IMAGE" @@ -516,6 +521,13 @@ jobs: $ARM64_IMAGE docker manifest push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:latest + # Create and push dated latest manifest (multi-arch) + docker manifest create \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:latest-${DATE} \ + $AMD64_IMAGE \ + $ARM64_IMAGE + docker manifest push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:latest-${DATE} + # Clean up temporary tags - name: Clean up temporary tags run: | diff --git a/cmd/altmount/cmd/serve.go b/cmd/altmount/cmd/serve.go index e42da2fe2..257a9e014 100644 --- a/cmd/altmount/cmd/serve.go +++ b/cmd/altmount/cmd/serve.go @@ -77,7 +77,7 @@ func runServe(cmd *cobra.Command, args []string) error { }() repos := setupRepositories(ctx, db) - poolManager := pool.NewManager(ctx) + poolManager := pool.NewManager(ctx, repos.MainRepo) metadataService, metadataReader := initializeMetadata(cfg) diff --git a/internal/api/server.go b/internal/api/server.go index 085a16c9f..733306673 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -229,6 +229,7 @@ func (s *Server) SetupRoutes(app *fiber.App) { api.Get("/system/health", s.handleGetSystemHealth) api.Get("/system/browse", s.handleSystemBrowse) api.Get("/system/pool/metrics", s.handleGetPoolMetrics) + api.Post("/system/stats/reset", s.handleResetSystemStats) api.Post("/system/cleanup", s.handleSystemCleanup) api.Post("/system/restart", s.handleSystemRestart) diff --git a/internal/api/system_handlers.go b/internal/api/system_handlers.go index 162be32e4..73aa207d4 100644 --- a/internal/api/system_handlers.go +++ b/internal/api/system_handlers.go @@ -213,6 +213,25 @@ func (s *Server) handleSystemRestart(c *fiber.Ctx) error { return result } +// handleResetSystemStats handles POST /api/system/stats/reset +func (s *Server) handleResetSystemStats(c *fiber.Ctx) error { + // Reset pool metrics + if s.poolManager != nil { + if err := s.poolManager.ResetMetrics(c.Context()); err != nil { + return c.Status(500).JSON(fiber.Map{ + "success": false, + "message": "Failed to reset pool metrics", + "details": err.Error(), + }) + } + } + + return c.Status(200).JSON(fiber.Map{ + "success": true, + "message": "System statistics reset successfully", + }) +} + // performRestart performs the actual server restart func (s *Server) performRestart(ctx context.Context) { slog.InfoContext(ctx, "Initiating server restart process") diff --git a/internal/database/repository.go b/internal/database/repository.go index 23213a2d8..0ddc5197b 100644 --- a/internal/database/repository.go +++ b/internal/database/repository.go @@ -1000,6 +1000,90 @@ func (r *Repository) GetSystemState(ctx context.Context, key string) (string, er return value, nil } +// UpdateSystemStat updates a numeric system statistic by key +func (r *Repository) UpdateSystemStat(ctx context.Context, key string, value int64) error { + query := ` + INSERT INTO system_stats (key, value, updated_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = datetime('now') + ` + _, err := r.db.ExecContext(ctx, query, key, value) + if err != nil { + return fmt.Errorf("failed to update system stat %s: %w", key, err) + } + return nil +} + +// BatchUpdateSystemStats updates multiple numeric system statistics in a single transaction +func (r *Repository) BatchUpdateSystemStats(ctx context.Context, stats map[string]int64) error { + if len(stats) == 0 { + return nil + } + + // Cast to *sql.DB to access BeginTx method + sqlDB, ok := r.db.(*sql.DB) + if !ok { + // If we're already in a transaction, we can just execute the statements + // However, it's better to provide a consistent way to handle this + return fmt.Errorf("repository not connected to sql.DB, cannot begin transaction") + } + + tx, err := sqlDB.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + query := ` + INSERT INTO system_stats (key, value, updated_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = datetime('now') + ` + + stmt, err := tx.PrepareContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to prepare statement: %w", err) + } + defer stmt.Close() + + for key, value := range stats { + if _, err := stmt.ExecContext(ctx, key, value); err != nil { + return fmt.Errorf("failed to execute statement for key %s: %w", key, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// GetSystemStats retrieves all numeric system statistics +func (r *Repository) GetSystemStats(ctx context.Context) (map[string]int64, error) { + query := `SELECT key, value FROM system_stats` + rows, err := r.db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to get system stats: %w", err) + } + defer rows.Close() + + stats := make(map[string]int64) + for rows.Next() { + var key string + var value int64 + if err := rows.Scan(&key, &value); err != nil { + return nil, fmt.Errorf("failed to scan system stat: %w", err) + } + stats[key] = value + } + return stats, nil +} + // GetImportHistory retrieves historical import statistics for the last N days func (r *Repository) GetImportHistory(ctx context.Context, days int) ([]*ImportDailyStat, error) { query := ` diff --git a/internal/pool/manager.go b/internal/pool/manager.go index 796677465..c39874caf 100644 --- a/internal/pool/manager.go +++ b/internal/pool/manager.go @@ -26,6 +26,16 @@ type Manager interface { // GetMetrics returns the current pool metrics with calculated speeds GetMetrics() (MetricsSnapshot, error) + + // ResetMetrics resets all cumulative metrics + ResetMetrics(ctx context.Context) error +} + +// StatsRepository defines the interface for persisting pool statistics +type StatsRepository interface { + UpdateSystemStat(ctx context.Context, key string, value int64) error + BatchUpdateSystemStats(ctx context.Context, stats map[string]int64) error + GetSystemStats(ctx context.Context) (map[string]int64, error) } // manager implements the Manager interface @@ -33,14 +43,16 @@ type manager struct { mu sync.RWMutex pool nntppool.UsenetConnectionPool metricsTracker *MetricsTracker + repo StatsRepository ctx context.Context logger *slog.Logger } // NewManager creates a new pool manager -func NewManager(ctx context.Context) Manager { +func NewManager(ctx context.Context, repo StatsRepository) Manager { return &manager{ ctx: ctx, + repo: repo, logger: slog.Default().With("component", "pool"), } } @@ -96,7 +108,7 @@ func (m *manager) SetProviders(providers []nntppool.UsenetProviderConfig) error m.pool = pool // Start metrics tracker - m.metricsTracker = NewMetricsTracker(pool) + m.metricsTracker = NewMetricsTracker(pool, m.repo) m.metricsTracker.Start(m.ctx) m.logger.InfoContext(m.ctx, "NNTP connection pool created successfully") @@ -142,5 +154,192 @@ func (m *manager) GetMetrics() (MetricsSnapshot, error) { return MetricsSnapshot{}, fmt.Errorf("metrics tracker not available") } - return m.metricsTracker.GetSnapshot(), nil -} \ No newline at end of file + return m.metricsTracker.GetSnapshot(), nil + + } + + + + // ResetMetrics resets all cumulative metrics + + + + func (m *manager) ResetMetrics(ctx context.Context) error { + + + + m.mu.Lock() + + + + defer m.mu.Unlock() + + + + + + + + if m.metricsTracker != nil { + + + + return m.metricsTracker.Reset(ctx) + + + + } + + + + + + + + // If tracker not available, still try to reset DB directly + + + + + + + + if m.repo != nil { + + + + + + + + currentStats, err := m.repo.GetSystemStats(ctx) + + + + + + + + if err == nil { + + + + + + + + resetMap := make(map[string]int64) + + + + + + + + for k := range currentStats { + + + + + + + + resetMap[k] = 0 + + + + + + + + } + + + + + + + + resetMap["bytes_downloaded"] = 0 + + + + + + + + resetMap["articles_downloaded"] = 0 + + + + + + + + resetMap["bytes_uploaded"] = 0 + + + + + + + + resetMap["articles_posted"] = 0 + + + + + + + + resetMap["max_download_speed"] = 0 + + + + + + + + _ = m.repo.BatchUpdateSystemStats(ctx, resetMap) + + + + + + + + } + + + + + + + + } + + + + + + + + + + + + + + + + return nil + + + + } + + + + + + \ No newline at end of file diff --git a/internal/pool/metrics_tracker.go b/internal/pool/metrics_tracker.go index 0729fab9e..aa401c167 100644 --- a/internal/pool/metrics_tracker.go +++ b/internal/pool/metrics_tracker.go @@ -3,6 +3,7 @@ package pool import ( "context" "log/slog" + "strings" "sync" "time" @@ -26,14 +27,24 @@ type MetricsSnapshot struct { // MetricsTracker tracks pool metrics over time and calculates rates type MetricsTracker struct { pool nntppool.UsenetConnectionPool + repo StatsRepository mu sync.RWMutex samples []metricsample sampleInterval time.Duration retentionPeriod time.Duration calculationWindow time.Duration // Window for speed calculations (shorter than retention for accuracy) maxDownloadSpeed float64 - cancel context.CancelFunc - logger *slog.Logger + // Persistent counters (loaded from DB on start) + initialBytesDownloaded int64 + initialArticlesDownloaded int64 + initialBytesUploaded int64 + initialArticlesPosted int64 + initialProviderErrors map[string]int64 + lastSavedBytesDownloaded int64 + persistenceThreshold int64 // Bytes to download before forcing a save + cancel context.CancelFunc + wg sync.WaitGroup + logger *slog.Logger } // metricsample represents a single metrics sample at a point in time @@ -48,15 +59,19 @@ type metricsample struct { } // NewMetricsTracker creates a new metrics tracker -func NewMetricsTracker(pool nntppool.UsenetConnectionPool) *MetricsTracker { +func NewMetricsTracker(pool nntppool.UsenetConnectionPool, repo StatsRepository) *MetricsTracker { mt := &MetricsTracker{ - pool: pool, - samples: make([]metricsample, 0, 60), // Preallocate for 60 samples - sampleInterval: 5 * time.Second, - retentionPeriod: 60 * time.Second, - calculationWindow: 10 * time.Second, // Use 10s window for more accurate real-time speeds - logger: slog.Default().With("component", "metrics-tracker"), - } + pool: pool, + repo: repo, + samples: make([]metricsample, 0, 60), // Preallocate for 60 samples + initialProviderErrors: make(map[string]int64), + sampleInterval: 5 * time.Second, + retentionPeriod: 60 * time.Second, + calculationWindow: 10 * time.Second, // Use 10s window for more accurate real-time speeds + persistenceThreshold: 1024 * 1024 * 1024, // Save every 1GB downloaded + logger: slog.Default().With("component", "metrics-tracker"), + } + return mt } @@ -66,10 +81,41 @@ func (mt *MetricsTracker) Start(ctx context.Context) { childCtx, cancel := context.WithCancel(ctx) mt.cancel = cancel + // Load initial stats from DB + if mt.repo != nil { + stats, err := mt.repo.GetSystemStats(ctx) + if err != nil { + mt.logger.ErrorContext(ctx, "Failed to load system stats from database", "error", err) + } else { + mt.mu.Lock() + mt.initialBytesDownloaded = stats["bytes_downloaded"] + mt.initialArticlesDownloaded = stats["articles_downloaded"] + mt.initialBytesUploaded = stats["bytes_uploaded"] + mt.initialArticlesPosted = stats["articles_posted"] + mt.maxDownloadSpeed = float64(stats["max_download_speed"]) + mt.lastSavedBytesDownloaded = mt.initialBytesDownloaded + + // Load provider errors (prefixed with provider_error:) + for k, v := range stats { + if strings.HasPrefix(k, "provider_error:") { + providerID := strings.TrimPrefix(k, "provider_error:") + mt.initialProviderErrors[providerID] = v + } + } + mt.mu.Unlock() + + mt.logger.InfoContext(ctx, "Loaded persistent system stats", + "articles", mt.initialArticlesDownloaded, + "bytes", mt.initialBytesDownloaded, + "provider_stats", len(mt.initialProviderErrors)) + } + } + // Take initial sample mt.takeSample() // Start sampling goroutine + mt.wg.Add(1) go mt.samplingLoop(childCtx) mt.logger.InfoContext(ctx, "Metrics tracker started", @@ -82,6 +128,7 @@ func (mt *MetricsTracker) Start(ctx context.Context) { func (mt *MetricsTracker) Stop() { if mt.cancel != nil { mt.cancel() + mt.wg.Wait() mt.logger.InfoContext(context.Background(), "Metrics tracker stopped") } } @@ -102,13 +149,22 @@ func (mt *MetricsTracker) GetSnapshot() MetricsSnapshot { mt.maxDownloadSpeed = downloadSpeed } + // Merge provider errors + mergedProviderErrors := make(map[string]int64) + for k, v := range mt.initialProviderErrors { + mergedProviderErrors[k] = v + } + for k, v := range poolSnapshot.ProviderErrors { + mergedProviderErrors[k] += v + } + return MetricsSnapshot{ - BytesDownloaded: poolSnapshot.BytesDownloaded, - BytesUploaded: poolSnapshot.BytesUploaded, - ArticlesDownloaded: poolSnapshot.ArticlesDownloaded, - ArticlesPosted: poolSnapshot.ArticlesPosted, + BytesDownloaded: poolSnapshot.BytesDownloaded + mt.initialBytesDownloaded, + BytesUploaded: poolSnapshot.BytesUploaded + mt.initialBytesUploaded, + ArticlesDownloaded: poolSnapshot.ArticlesDownloaded + mt.initialArticlesDownloaded, + ArticlesPosted: poolSnapshot.ArticlesPosted + mt.initialArticlesPosted, TotalErrors: poolSnapshot.TotalErrors, - ProviderErrors: poolSnapshot.ProviderErrors, + ProviderErrors: mergedProviderErrors, DownloadSpeedBytesPerSec: downloadSpeed, MaxDownloadSpeedBytesPerSec: mt.maxDownloadSpeed, UploadSpeedBytesPerSec: uploadSpeed, @@ -118,17 +174,105 @@ func (mt *MetricsTracker) GetSnapshot() MetricsSnapshot { // samplingLoop periodically samples metrics func (mt *MetricsTracker) samplingLoop(ctx context.Context) { + defer mt.wg.Done() ticker := time.NewTicker(mt.sampleInterval) defer ticker.Stop() + // Use a longer interval for DB updates to avoid excessive writes + dbUpdateTicker := time.NewTicker(1 * time.Minute) + defer dbUpdateTicker.Stop() + for { select { case <-ctx.Done(): + // Final save on shutdown + mt.saveStats(context.Background()) return case <-ticker.C: mt.takeSample() + case <-dbUpdateTicker.C: + mt.saveStats(ctx) + } + } +} + +// saveStats persists current totals to the database +func (mt *MetricsTracker) saveStats(ctx context.Context) { + if mt.repo == nil { + return + } + + snapshot := mt.GetSnapshot() + + // Prepare batch update + stats := map[string]int64{ + "bytes_downloaded": snapshot.BytesDownloaded, + "articles_downloaded": snapshot.ArticlesDownloaded, + "bytes_uploaded": snapshot.BytesUploaded, + "articles_posted": snapshot.ArticlesPosted, + "max_download_speed": int64(snapshot.MaxDownloadSpeedBytesPerSec), + } + + // Add provider errors to batch + for providerID, errorCount := range snapshot.ProviderErrors { + stats["provider_error:"+providerID] = errorCount + } + + if err := mt.repo.BatchUpdateSystemStats(ctx, stats); err != nil { + mt.logger.ErrorContext(ctx, "Failed to persist system stats", "error", err) + } else { + mt.mu.Lock() + mt.lastSavedBytesDownloaded = snapshot.BytesDownloaded + mt.mu.Unlock() + } +} + +// Reset resets all cumulative metrics both in memory and in the database +func (mt *MetricsTracker) Reset(ctx context.Context) error { + mt.mu.Lock() + defer mt.mu.Unlock() + + // Reset in-memory cumulative values + // Since we can't easily reset the nntppool's internal counters without recreating it, + // we adjust our "initial" values so that Displayed = Initial + Pool becomes 0. + poolSnapshot := mt.pool.GetMetricsSnapshot() + + mt.initialBytesDownloaded = -poolSnapshot.BytesDownloaded + mt.initialArticlesDownloaded = -poolSnapshot.ArticlesDownloaded + mt.initialBytesUploaded = -poolSnapshot.BytesUploaded + mt.initialArticlesPosted = -poolSnapshot.ArticlesPosted + mt.maxDownloadSpeed = 0 + mt.initialProviderErrors = make(map[string]int64) + + // Clear samples to reset speed calculation + mt.samples = make([]metricsample, 0, 60) + + // Persist the reset state to database + if mt.repo != nil { + // We need to fetch all current keys to know what to reset (especially provider errors) + currentStats, err := mt.repo.GetSystemStats(ctx) + if err != nil { + mt.logger.ErrorContext(ctx, "Failed to fetch stats for reset", "error", err) + } else { + resetMap := make(map[string]int64) + for k := range currentStats { + resetMap[k] = 0 + } + // Ensure core keys are present + resetMap["bytes_downloaded"] = 0 + resetMap["articles_downloaded"] = 0 + resetMap["bytes_uploaded"] = 0 + resetMap["articles_posted"] = 0 + resetMap["max_download_speed"] = 0 + + if err := mt.repo.BatchUpdateSystemStats(ctx, resetMap); err != nil { + mt.logger.ErrorContext(ctx, "Failed to persist reset stats", "error", err) + } } } + + mt.logger.InfoContext(ctx, "Pool metrics have been reset") + return nil } // takeSample captures a metrics snapshot and stores it @@ -138,7 +282,7 @@ func (mt *MetricsTracker) takeSample() { mt.mu.Lock() defer mt.mu.Unlock() - // Create sample + // Create sample (pool-relative values are fine for speed calculation) sample := metricsample{ bytesDownloaded: snapshot.BytesDownloaded, bytesUploaded: snapshot.BytesUploaded, @@ -152,6 +296,14 @@ func (mt *MetricsTracker) takeSample() { // Add sample mt.samples = append(mt.samples, sample) + // Adaptive Persistence: Check if we should force a save due to high activity + totalBytesDownloaded := snapshot.BytesDownloaded + mt.initialBytesDownloaded + if totalBytesDownloaded-mt.lastSavedBytesDownloaded >= mt.persistenceThreshold { + // Use a non-blocking save or a shorter context? + // For now, simple call is fine as it's a goroutine + go mt.saveStats(context.Background()) + } + // Clean up old samples mt.cleanupOldSamples() }