feat: wire the latest-chapter poller into startup
Five env vars, all with defaults sized for this deployment (84 series at a 1h cadence). Bad values log and fall back rather than failing startup, and the cooldown is floored at 15m. Fetches go through tls-client with a Chrome profile as defence in depth.
This commit is contained in:
+134
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -20,8 +21,34 @@ type Config struct {
|
||||
Port string
|
||||
// WebPassword gates the browser UI. Empty disables the web routes entirely.
|
||||
WebPassword string
|
||||
// LatestPoll configures the background latest-chapter fetcher.
|
||||
LatestPoll LatestPoll
|
||||
}
|
||||
|
||||
// LatestPoll configures the background latest-chapter poller.
|
||||
//
|
||||
// Sizing: batch x (cooldown / interval) is how many series hold a true cooldown
|
||||
// cadence — 14 x (1h / 10m) = 84 with these defaults, which covers this
|
||||
// deployment. Past that nothing breaks; the effective cadence stretches to
|
||||
// N x interval / batch and the oldest-checked-first ordering keeps it uniform.
|
||||
type LatestPoll struct {
|
||||
Enabled bool
|
||||
Cooldown time.Duration
|
||||
Interval time.Duration
|
||||
Stagger time.Duration
|
||||
Batch int
|
||||
}
|
||||
|
||||
const (
|
||||
defaultPollCooldown = time.Hour
|
||||
defaultPollInterval = 10 * time.Minute
|
||||
defaultPollStagger = 20 * time.Second
|
||||
defaultPollBatch = 14
|
||||
// minPollCooldown keeps a typo from turning a polite background check into
|
||||
// a hammer against sites that are already bot-scoring us.
|
||||
minPollCooldown = 15 * time.Minute
|
||||
)
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
@@ -29,12 +56,83 @@ func envOr(key, def string) string {
|
||||
return def
|
||||
}
|
||||
|
||||
// envBool reads a boolean env var. Anything unrecognised falls back to def.
|
||||
func envBool(key string, def bool) bool {
|
||||
switch v := strings.ToLower(strings.TrimSpace(os.Getenv(key))); v {
|
||||
case "":
|
||||
return def
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
log.Printf("config: %s=%q is not a boolean, using %v", key, v, def)
|
||||
return def
|
||||
}
|
||||
}
|
||||
|
||||
// envDuration reads a duration env var. An unparseable or non-positive value
|
||||
// falls back to def and logs rather than failing startup: the poller is an
|
||||
// enhancement, and a typo in one of its knobs must not stop bookmark sync.
|
||||
func envDuration(key string, def time.Duration) time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil || d <= 0 {
|
||||
log.Printf("config: %s=%q is not a positive duration, using %s", key, raw, def)
|
||||
return def
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// envInt reads a positive integer env var, with the same fallback policy.
|
||||
func envInt(key string, def int) int {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n <= 0 {
|
||||
log.Printf("config: %s=%q is not a positive integer, using %d", key, raw, def)
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// loadLatestPoll reads the poller's settings, clamping anything that would make
|
||||
// it antisocial.
|
||||
func loadLatestPoll() LatestPoll {
|
||||
p := LatestPoll{
|
||||
Enabled: envBool("LATEST_CHAPTER_POLL_ENABLED", true),
|
||||
Cooldown: envDuration("LATEST_CHAPTER_POLL_COOLDOWN", defaultPollCooldown),
|
||||
Interval: envDuration("LATEST_CHAPTER_POLL_INTERVAL", defaultPollInterval),
|
||||
Stagger: envDuration("LATEST_CHAPTER_POLL_STAGGER", defaultPollStagger),
|
||||
Batch: envInt("LATEST_CHAPTER_POLL_BATCH", defaultPollBatch),
|
||||
}
|
||||
if p.Cooldown < minPollCooldown {
|
||||
log.Printf("config: cooldown %s is below the %s floor, clamping", p.Cooldown, minPollCooldown)
|
||||
p.Cooldown = minPollCooldown
|
||||
}
|
||||
// batch x stagger has to fit inside one tick or a batch is still running
|
||||
// when the next one is due. Run() serialises them, so this degrades to a
|
||||
// slower cadence rather than to overlapping fetches — worth a warning, not
|
||||
// a failure.
|
||||
if span := time.Duration(p.Batch) * p.Stagger; span > p.Interval {
|
||||
log.Printf("config: batch(%d) x stagger(%s) = %s exceeds interval %s; batches will overrun their tick",
|
||||
p.Batch, p.Stagger, span, p.Interval)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func loadConfig() Config {
|
||||
c := Config{
|
||||
Token: os.Getenv("API_TOKEN"),
|
||||
DBPath: envOr("DB_PATH", "/data/bookmarks.db"),
|
||||
Port: envOr("PORT", "8080"),
|
||||
WebPassword: os.Getenv("WEB_PASSWORD"),
|
||||
LatestPoll: loadLatestPoll(),
|
||||
}
|
||||
for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") {
|
||||
if o = strings.TrimSpace(o); o != "" {
|
||||
@@ -87,6 +185,13 @@ func main() {
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// The poller is off the request path entirely: if it cannot start, the
|
||||
// service still serves bookmarks and the userscript still captures latest
|
||||
// chapters on its own.
|
||||
pollCtx, stopPoll := context.WithCancel(context.Background())
|
||||
defer stopPoll()
|
||||
startLatestPoller(pollCtx, store, cfg.LatestPoll)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: newRouter(store, cfg),
|
||||
@@ -105,9 +210,38 @@ func main() {
|
||||
<-stop
|
||||
|
||||
log.Println("shutting down")
|
||||
// Stop polling before draining requests, so an in-flight series fetch does
|
||||
// not hold the process open past the shutdown deadline.
|
||||
stopPoll()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Printf("shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// startLatestPoller launches the background poller unless it is disabled or its
|
||||
// HTTP client cannot be built. Any problem here is logged and skipped: this
|
||||
// feature going missing degrades the service to userscript-only latest-chapter
|
||||
// tracking, which is exactly how it behaved before.
|
||||
func startLatestPoller(ctx context.Context, store *Store, cfg LatestPoll) {
|
||||
if !cfg.Enabled {
|
||||
log.Println("latest-chapter poller: disabled by config")
|
||||
return
|
||||
}
|
||||
f, err := newTLSFetcher()
|
||||
if err != nil {
|
||||
log.Printf("latest-chapter poller: disabled, cannot build client: %v", err)
|
||||
return
|
||||
}
|
||||
p := &latestPoller{
|
||||
store: store,
|
||||
fetch: f,
|
||||
now: time.Now,
|
||||
cooldown: cfg.Cooldown,
|
||||
interval: cfg.Interval,
|
||||
stagger: cfg.Stagger,
|
||||
batch: cfg.Batch,
|
||||
}
|
||||
go p.Run(ctx)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user