Change interval

This commit is contained in:
2026-08-20 09:32:04 +02:00
parent 86e644410b
commit b9f4a851b3
5 changed files with 129 additions and 47 deletions
+37 -10
View File
@@ -8,9 +8,12 @@ A small single-container Python 3 uptime monitor.
- **DNS resolution never uses the system/router-provided resolver** — - **DNS resolution never uses the system/router-provided resolver** —
every lookup is sent explicitly and only to the DNS server configured every lookup is sent explicitly and only to the DNS server configured
for that endpoint (see "How DNS enforcement works" below). for that endpoint (see "How DNS enforcement works" below).
- Runs a full rolling pass over all endpoints every 10 minutes (configurable). - Checks are spread evenly across `interval_seconds` rather than all
firing at once: with N endpoints, one is checked every
`interval_seconds / N` seconds, rotating through all of them (see
"Scheduling" below).
- If any endpoint fails, all endpoints are immediately re-checked once, - If any endpoint fails, all endpoints are immediately re-checked once,
instead of waiting for the next scheduled pass. instead of waiting for their normal rotation slot.
- Writes every check result to an InfluxDB 2.x bucket (`uptime`) over - Writes every check result to an InfluxDB 2.x bucket (`uptime`) over
HTTPS, with certificate verification disabled for InfluxDB specifically HTTPS, with certificate verification disabled for InfluxDB specifically
(self-signed cert). (self-signed cert).
@@ -47,7 +50,8 @@ influx:
verify_ssl: false # keep false: influx uses a self-signed cert verify_ssl: false # keep false: influx uses a self-signed cert
check: check:
interval_seconds: 600 # 10 min rolling interval interval_seconds: 600 # each endpoint checked once per this many seconds,
# spread evenly across N endpoints (see Scheduling below)
timeout_seconds: 10 # default per-check timeout timeout_seconds: 10 # default per-check timeout
immediate_recheck_delay_seconds: 5 # pause before the failure-triggered recheck immediate_recheck_delay_seconds: 5 # pause before the failure-triggered recheck
@@ -106,15 +110,38 @@ from(bucket: "uptime")
|> filter(fn: (r) => r._field == "success") |> filter(fn: (r) => r._field == "success")
``` ```
## Scheduling
Endpoints are **not** all checked together and then slept on as a batch.
Each endpoint gets its own slot, spread evenly across `interval_seconds`:
with N endpoints configured, one is checked every
`interval_seconds / N` seconds, rotating through all of them. So
`interval_seconds: 3600` with 6 endpoints means one endpoint is checked
every 10 minutes — each individual endpoint is still only checked once
per hour, but the 6 checks are spread across that hour instead of firing
all at once, then waiting an hour.
Example with 3 endpoints and `interval_seconds: 600` (so ~200s apart):
```
t=0s endpoint A checked
t=200s endpoint B checked
t=400s endpoint C checked
t=600s endpoint A checked again (600s after its previous check)
t=800s endpoint B checked again
...
```
## Failure / immediate recheck behavior ## Failure / immediate recheck behavior
- Every `check.interval_seconds`, all endpoints are checked in sequence. - If any single endpoint check fails, **all** endpoints are checked once,
- If any endpoint fails that pass, after `immediate_recheck_delay_seconds` immediately, after `immediate_recheck_delay_seconds` — instead of
all endpoints are checked again immediately (one extra pass). waiting for their normal rotation slot.
- The monitor then returns to the normal `interval_seconds` schedule. - The rotation is then re-spread evenly from that point, so it doesn't
If the outage continues, the next scheduled pass will detect it again bunch endpoints back up onto the same slot, and resumes normally.
and trigger another single immediate recheck — it does not hammer the - If the outage continues, that endpoint's next regular slot will detect
endpoint in a tight loop. it again and trigger another single immediate recheck of everyone —
it does not hammer the endpoint in a tight loop.
## Local dev (without Docker) ## Local dev (without Docker)
+8 -4
View File
@@ -75,10 +75,7 @@ def check_endpoint(ep, default_timeout: float, timestamp_ns: int) -> CheckResult
) )
def check_all(endpoints, default_timeout: float, timestamp_ns: int) -> List[CheckResult]: def log_result(result: CheckResult) -> None:
results = []
for ep in endpoints:
result = check_endpoint(ep, default_timeout, timestamp_ns)
level = logging.INFO if result.success else logging.WARNING level = logging.INFO if result.success else logging.WARNING
logger.log( logger.log(
level, level,
@@ -88,5 +85,12 @@ def check_all(endpoints, default_timeout: float, timestamp_ns: int) -> List[Chec
None if result.response_time_ms is None else round(result.response_time_ms, 1), None if result.response_time_ms is None else round(result.response_time_ms, 1),
result.error, result.error,
) )
def check_all(endpoints, default_timeout: float, timestamp_ns: int) -> List[CheckResult]:
results = []
for ep in endpoints:
result = check_endpoint(ep, default_timeout, timestamp_ns)
log_result(result)
results.append(result) results.append(result)
return results return results
+2 -23
View File
@@ -3,11 +3,9 @@ from __future__ import annotations
import logging import logging
import os import os
import sys import sys
import time
from app.checker import check_all
from app.config import ConfigError, load_config from app.config import ConfigError, load_config
from app.influx_writer import write_results from app.scheduler import run_forever
logger = logging.getLogger("uptime_monitor") logger = logging.getLogger("uptime_monitor")
@@ -20,13 +18,6 @@ def setup_logging() -> None:
) )
def run_cycle(cfg) -> list:
timestamp_ns = time.time_ns()
results = check_all(cfg.endpoints, cfg.check.timeout_seconds, timestamp_ns)
write_results(cfg.influx, results)
return results
def main() -> None: def main() -> None:
setup_logging() setup_logging()
config_path = os.environ.get("CONFIG_PATH", "/config/config.yaml") config_path = os.environ.get("CONFIG_PATH", "/config/config.yaml")
@@ -42,19 +33,7 @@ def main() -> None:
len(cfg.endpoints), config_path, cfg.check.interval_seconds, len(cfg.endpoints), config_path, cfg.check.interval_seconds,
) )
while True: run_forever(cfg)
results = run_cycle(cfg)
if any(not r.success for r in results):
logger.warning(
"at least one endpoint failed this cycle; "
"re-checking all endpoints immediately in %.0fs",
cfg.check.immediate_recheck_delay_seconds,
)
time.sleep(cfg.check.immediate_recheck_delay_seconds)
run_cycle(cfg)
time.sleep(cfg.check.interval_seconds)
if __name__ == "__main__": if __name__ == "__main__":
+67
View File
@@ -0,0 +1,67 @@
"""Rolling scheduler: spreads endpoint checks evenly across the interval.
With N endpoints and `interval_seconds` = T, each endpoint gets its own
slot, T/N seconds apart, and is checked once every T seconds — e.g. 6
endpoints with interval_seconds: 3600 means one endpoint is checked every
10 minutes, rotating through all six over the course of an hour. This is
NOT "check everything, then sleep T seconds": individual endpoint checks
are spread out, not bunched together.
If any single check fails, ALL endpoints are checked once immediately
(out of band) as an extra confirmation pass, and the rotation is then
re-staggered from that point so it stays evenly spread afterward.
"""
from __future__ import annotations
import logging
import time
from typing import List
from .checker import check_all, check_endpoint, log_result
from .influx_writer import write_results
logger = logging.getLogger("uptime_monitor.scheduler")
def run_forever(cfg) -> None:
endpoints = cfg.endpoints
interval = cfg.check.interval_seconds
n = len(endpoints)
stagger = interval / n
now = time.monotonic()
next_at: List[float] = [now + i * stagger for i in range(n)]
logger.info(
"rolling schedule: %d endpoint(s) spread across %ss (~%.1fs apart)",
n, interval, stagger,
)
while True:
idx = min(range(n), key=lambda i: next_at[i])
wait = next_at[idx] - time.monotonic()
if wait > 0:
time.sleep(wait)
ep = endpoints[idx]
result = check_endpoint(ep, cfg.check.timeout_seconds, time.time_ns())
log_result(result)
write_results(cfg.influx, [result])
# schedule this endpoint's next regular slot
next_at[idx] += interval
if not result.success:
logger.warning(
"endpoint=%s failed; re-checking all endpoints immediately in %.0fs",
ep.name, cfg.check.immediate_recheck_delay_seconds,
)
time.sleep(cfg.check.immediate_recheck_delay_seconds)
results = check_all(endpoints, cfg.check.timeout_seconds, time.time_ns())
write_results(cfg.influx, results)
# Re-stagger everyone from now so the rotation stays evenly
# spread out afterward, instead of bunching back up.
now = time.monotonic()
next_at = [now + (i + 1) * stagger for i in range(n)]
+10 -5
View File
@@ -24,17 +24,22 @@ influx:
verify_ssl: false verify_ssl: false
check: check:
# How often to run the full rolling check of all endpoints, in seconds. # Each endpoint is checked once every interval_seconds, but checks are
# 600 = 10 minutes. # spread evenly across that window rather than all fired at once — with
# N endpoints configured, one endpoint is checked every
# (interval_seconds / N) seconds, rotating through all of them.
# Example: 6 endpoints + interval_seconds: 3600 (1 hour) -> one endpoint
# checked every 10 minutes.
interval_seconds: 600 interval_seconds: 600
# Default per-request timeout (DNS lookup + HTTP request), in seconds. # Default per-request timeout (DNS lookup + HTTP request), in seconds.
# Can be overridden per endpoint. # Can be overridden per endpoint.
timeout_seconds: 10 timeout_seconds: 10
# If any endpoint fails during a cycle, all endpoints are re-checked # If any single endpoint check fails, ALL endpoints are checked once,
# once, immediately, after this short delay (in seconds) — instead of # immediately, after this short delay (in seconds) — instead of waiting
# waiting for the next scheduled cycle. Regular scheduling then resumes. # for their normal rotation slot. The rotation is then re-spread evenly
# from that point and resumes normally.
immediate_recheck_delay_seconds: 5 immediate_recheck_delay_seconds: 5
endpoints: endpoints: