|
5 | 5 | "context" |
6 | 6 | "fmt" |
7 | 7 | "log/slog" |
| 8 | + "math" |
8 | 9 | "sync" |
9 | 10 | "time" |
10 | 11 |
|
@@ -120,8 +121,8 @@ func (m *Monitor) CheckAll(ctx context.Context) error { |
120 | 121 |
|
121 | 122 | // Use any subscriber's thread info to check intervals (they should all be the same) |
122 | 123 | thread := info.thread |
123 | | - interval, reason := calculateInterval(thread.LastPostTime, thread.LastPolledAt) |
124 | | - timeSinceLastPoll := time.Now().Sub(thread.LastPolledAt) |
| 124 | + interval, reason := CalculateInterval(thread.LastPostTime, thread.LastPolledAt) |
| 125 | + timeSinceLastPoll := time.Since(thread.LastPolledAt) |
125 | 126 | needsCheck := timeSinceLastPoll >= interval |
126 | 127 |
|
127 | 128 | // Format times for logging, handling zero values |
@@ -472,48 +473,58 @@ func (m *Monitor) checkThreadForSubscribers(ctx context.Context, info *threadChe |
472 | 473 | return hasUpdates, savedEmails, nil |
473 | 474 | } |
474 | 475 |
|
475 | | -// calculateInterval determines how often to poll a thread based on activity. |
476 | | -// Returns the interval duration and a human-readable reason explaining the decision. |
| 476 | +// CalculateInterval determines how often to poll a thread based on activity. |
| 477 | +// Uses exponential backoff: the longer since the last post, the less frequently we check. |
| 478 | +// Formula: interval = min(minInterval * 2^(hours_since_post / scaleFactor), maxInterval) |
| 479 | +// |
| 480 | +// This provides smooth scaling: |
| 481 | +// - 0h since post → 5 minutes |
| 482 | +// - 3h since post → 10 minutes |
| 483 | +// - 6h since post → 20 minutes |
| 484 | +// - 12h since post → 80 minutes |
| 485 | +// - 24h+ since post → 4 hours (capped) |
| 486 | +// |
477 | 487 | // NEVER returns 0s - always returns a minimum interval to prevent polling loops. |
478 | | -func calculateInterval(lastPostTime, lastPolledAt time.Time) (time.Duration, string) { |
| 488 | +func CalculateInterval(lastPostTime, lastPolledAt time.Time) (time.Duration, string) { |
479 | 489 | const minInterval = 5 * time.Minute // Minimum safe interval |
| 490 | + const maxInterval = 4 * time.Hour // Maximum interval for inactive threads |
| 491 | + const scaleFactor = 3.0 // Hours before interval doubles (smaller = more aggressive backoff) |
480 | 492 |
|
481 | | - // If never polled before, use minimum interval |
| 493 | + // CRITICAL: These should NEVER be zero after subscription creation. |
| 494 | + // If they are, it indicates a serious bug in subscription or polling logic. |
482 | 495 | if lastPolledAt.IsZero() { |
483 | | - return minInterval, "never polled before (using minimum interval)" |
| 496 | + return maxInterval, "CRITICAL ERROR: LastPolledAt is zero (this should never happen - bug in subscription creation)" |
484 | 497 | } |
485 | 498 |
|
486 | | - // If no post time recorded, use maximum interval (something is wrong) |
487 | 499 | if lastPostTime.IsZero() { |
488 | | - return 6 * time.Hour, "ERROR: no post time recorded (using maximum interval to avoid polling loop)" |
| 500 | + return maxInterval, "CRITICAL ERROR: LastPostTime is zero (this should never happen - timestamp validation failed)" |
489 | 501 | } |
490 | 502 |
|
491 | 503 | // Calculate time since last post |
492 | | - timeSinceLastPost := time.Since(lastPostTime) |
| 504 | + hoursSincePost := time.Since(lastPostTime).Hours() |
493 | 505 |
|
494 | | - var interval time.Duration |
| 506 | + // Exponential backoff: interval doubles every scaleFactor hours |
| 507 | + // Example with scaleFactor=3: 0h→5m, 3h→10m, 6h→20m, 9h→40m, 12h→80m |
| 508 | + multiplier := math.Pow(2.0, hoursSincePost/scaleFactor) |
| 509 | + interval := time.Duration(float64(minInterval) * multiplier) |
| 510 | + |
| 511 | + // Clamp to min/max bounds |
| 512 | + if interval > maxInterval { |
| 513 | + interval = maxInterval |
| 514 | + } |
| 515 | + if interval < minInterval { |
| 516 | + interval = minInterval |
| 517 | + } |
| 518 | + |
| 519 | + // Format reason with readable time units |
495 | 520 | var reason string |
496 | 521 | switch { |
497 | | - case timeSinceLastPost < 30*time.Minute: |
498 | | - interval = 5 * time.Minute |
499 | | - reason = "very active thread (post < 30m ago)" |
500 | | - case timeSinceLastPost < 2*time.Hour: |
501 | | - interval = 10 * time.Minute |
502 | | - reason = "active thread (post < 2h ago)" |
503 | | - case timeSinceLastPost < 6*time.Hour: |
504 | | - interval = 20 * time.Minute |
505 | | - reason = "moderately active thread (post < 6h ago)" |
506 | | - case timeSinceLastPost < 24*time.Hour: |
507 | | - interval = 1 * time.Hour |
508 | | - reason = "daily active thread (post < 24h ago)" |
| 522 | + case hoursSincePost < 1: |
| 523 | + reason = fmt.Sprintf("%.0f minutes since last post", hoursSincePost*60) |
| 524 | + case hoursSincePost < 48: |
| 525 | + reason = fmt.Sprintf("%.1f hours since last post", hoursSincePost) |
509 | 526 | default: |
510 | | - interval = 6 * time.Hour |
511 | | - reason = "inactive thread (post > 24h ago)" |
512 | | - } |
513 | | - |
514 | | - // Safety check: never return 0s interval |
515 | | - if interval == 0 { |
516 | | - return minInterval, "ERROR: interval calculation resulted in 0s (using minimum interval)" |
| 527 | + reason = fmt.Sprintf("%.0f days since last post", hoursSincePost/24) |
517 | 528 | } |
518 | 529 |
|
519 | 530 | return interval, reason |
|
0 commit comments