Skip to content

Commit 8660cd6

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
use an algorithm approach for calculating how often to query
1 parent 109df6a commit 8660cd6

File tree

3 files changed

+89
-34
lines changed

3 files changed

+89
-34
lines changed

poll/poll.go

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"fmt"
77
"log/slog"
8+
"math"
89
"sync"
910
"time"
1011

@@ -120,8 +121,8 @@ func (m *Monitor) CheckAll(ctx context.Context) error {
120121

121122
// Use any subscriber's thread info to check intervals (they should all be the same)
122123
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)
125126
needsCheck := timeSinceLastPoll >= interval
126127

127128
// Format times for logging, handling zero values
@@ -472,48 +473,58 @@ func (m *Monitor) checkThreadForSubscribers(ctx context.Context, info *threadChe
472473
return hasUpdates, savedEmails, nil
473474
}
474475

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+
//
477487
// 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) {
479489
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)
480492

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.
482495
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)"
484497
}
485498

486-
// If no post time recorded, use maximum interval (something is wrong)
487499
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)"
489501
}
490502

491503
// Calculate time since last post
492-
timeSinceLastPost := time.Since(lastPostTime)
504+
hoursSincePost := time.Since(lastPostTime).Hours()
493505

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
495520
var reason string
496521
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)
509526
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)
517528
}
518529

519530
return interval, reason

server/subscribe.go

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package server
22

33
import (
4+
"context"
45
"fmt"
56
"net/http"
67
"strings"
78
"time"
89

910
"advrider-notifier/pkg/notifier"
11+
"advrider-notifier/poll"
1012
)
1113

1214
func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request) {
@@ -146,6 +148,8 @@ func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request) {
146148
return
147149
}
148150

151+
now := time.Now().UTC()
152+
149153
s.logger.Info("Creating subscription with latest post ID",
150154
"email", email,
151155
"thread_id", threadID,
@@ -154,14 +158,15 @@ func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request) {
154158
"last_post_time", lastPostTime.Format(time.RFC3339))
155159

156160
// Add thread to subscription
161+
// Set LastPolledAt to now since we just fetched the thread during subscription
157162
sub.Threads[threadID] = &notifier.Thread{
158163
ThreadURL: baseThreadURL,
159164
ThreadID: threadID,
160165
ThreadTitle: threadTitle,
161166
LastPostID: post.ID,
162167
LastPostTime: lastPostTime,
163-
LastPolledAt: time.Time{}, // Will be set on first poll
164-
CreatedAt: time.Now().UTC(),
168+
LastPolledAt: now, // Set to now since we just verified the thread
169+
CreatedAt: now,
165170
}
166171

167172
if err := s.store.Save(r.Context(), sub); err != nil {
@@ -170,22 +175,60 @@ func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request) {
170175
return
171176
}
172177

178+
s.logger.Info("Subscription created", "email", email, "thread_id", threadID, "ip", ip)
179+
180+
// Trigger immediate poll to notify all existing subscribers about any new posts
181+
// This runs asynchronously so we don't block the HTTP response
182+
// Use background context since we don't want this tied to the HTTP request lifecycle
183+
go func() {
184+
pollCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
185+
defer cancel()
186+
187+
s.logger.Info("Triggering poll after new subscription", "thread_url", baseThreadURL, "email", email)
188+
if err := s.poller.CheckAll(pollCtx); err != nil {
189+
s.logger.Warn("Post-subscription poll failed", "error", err)
190+
}
191+
}()
192+
173193
// Send welcome email
174194
userAgent := r.Header.Get("User-Agent")
175195
if err := s.emailer.SendWelcome(r.Context(), sub, sub.Threads[threadID], ip, userAgent); err != nil {
176196
// Log error but don't fail the subscription
177197
s.logger.Warn("Failed to send welcome email", "email", email, "error", err)
178198
}
179199

180-
s.logger.Info("Subscription created", "email", email, "thread_id", threadID, "ip", ip)
200+
// Calculate next crawl time based on thread activity
201+
interval, reason := poll.CalculateInterval(lastPostTime, now)
202+
nextCrawlTime := now.Add(interval)
203+
204+
// Format time delta for display
205+
var crawlTimeStr string
206+
switch {
207+
case interval < time.Hour:
208+
crawlTimeStr = fmt.Sprintf("%d minutes", int(interval.Minutes()))
209+
case interval < 2*time.Hour:
210+
crawlTimeStr = "1 hour"
211+
default:
212+
crawlTimeStr = fmt.Sprintf("%d hours", int(interval.Hours()))
213+
}
214+
215+
s.logger.Info("Subscription completed",
216+
"email", email,
217+
"thread_id", threadID,
218+
"next_crawl_in", interval.String(),
219+
"crawl_reason", reason)
181220

182221
// Set cookie to remember email address
183222
setEmailCookie(w, email)
184223

185224
w.Header().Set("Content-Type", "text/html; charset=utf-8")
186225
w.Header().Set("X-Content-Type-Options", "nosniff")
187226
w.WriteHeader(http.StatusOK)
188-
if err := templates.ExecuteTemplate(w, "subscribed.tmpl", map[string]string{"Email": email}); err != nil {
227+
if err := templates.ExecuteTemplate(w, "subscribed.tmpl", map[string]any{
228+
"Email": email,
229+
"CrawlTime": crawlTimeStr,
230+
"NextCrawlAt": nextCrawlTime.Format("3:04 PM MST"),
231+
}); err != nil {
189232
s.logger.Error("Failed to render template", "template", "subscribed.tmpl", "error", err)
190233
http.Error(w, "Internal server error", http.StatusInternalServerError)
191234
}

server/tmpl/subscribed.tmpl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<div class="icon">✓</div>
2020
<h1>Subscription Created!</h1>
2121
<p>You'll receive an email at <strong>{{.Email}}</strong> whenever new posts appear on this thread.</p>
22+
<p style="font-size: 15px; color: #666; margin-top: 16px;">Next check scheduled in approximately <strong>{{.CrawlTime}}</strong> ({{.NextCrawlAt}})</p>
2223
<p style="font-size: 15px; color: #999;">Each email will include a secure link to manage your subscriptions.</p>
2324
<a href="/" class="button">Subscribe to Another Thread</a>
2425
</div>

0 commit comments

Comments
 (0)