fix(traefik): the rows page reads every router's history in one index-only query (#5355)
#5355 "Traefik page doesn't load": /plugins/traefik/rows took 12-28 s. It ran a query per router ever seen, and each raw row in the range was a heap fetch. traefik_004 rebuilds the router index to cover the charted columns and indexes scraped_at for the request chart; router_history reads the current routers' buckets in one query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
"""Traefik reads answered from the index alone
|
||||
|
||||
The rows page and the request chart read every raw row in the chosen range.
|
||||
With only (router_name, scraped_at) indexed, each of those rows was a heap
|
||||
fetch: a random disk read on a database whose indexes no longer fit in
|
||||
memory, which took the rows page to 12-28 s. Carrying the charted columns
|
||||
in the index makes these index-only scans; scraped_at gets an index of its
|
||||
own for the request chart, which filters on time across every router.
|
||||
|
||||
Revision ID: traefik_004_covering_indexes
|
||||
Revises: traefik_003_access_log
|
||||
Create Date: 2026-10-08
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
|
||||
revision: str = "traefik_004_covering_indexes"
|
||||
down_revision: Union[str, None] = "traefik_003_access_log"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_index("ix_traefik_metrics_router_scraped", "traefik_metrics")
|
||||
op.create_index("ix_traefik_metrics_router_scraped", "traefik_metrics", ["router_name", "scraped_at"],
|
||||
postgresql_include=["request_rate", "latency_p95_ms", "error_rate_5xx_pct"])
|
||||
op.create_index("ix_traefik_metrics_scraped", "traefik_metrics", ["scraped_at"],
|
||||
postgresql_include=["request_rate", "error_rate_5xx_pct"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_traefik_metrics_scraped", "traefik_metrics")
|
||||
op.drop_index("ix_traefik_metrics_router_scraped", "traefik_metrics")
|
||||
op.create_index("ix_traefik_metrics_router_scraped", "traefik_metrics", ["router_name", "scraped_at"])
|
||||
+41
-38
@@ -180,44 +180,21 @@ async def rows():
|
||||
now = datetime.now(timezone.utc)
|
||||
thresholds = current_app.config.get("THRESHOLDS", {})
|
||||
|
||||
scrape = current_app.config["PLUGINS"]["traefik"]["scrape_interval_seconds"]
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
# Every known router, with its most recent raw row
|
||||
newest = {m.router_name: m for m in await latest_per_router(db)}
|
||||
routers = sorted(newest)
|
||||
# Every known router, with its most recent raw row; a router Traefik
|
||||
# stopped reporting gets no sparklines, so its history is not read.
|
||||
newest = await latest_per_router(db)
|
||||
current, gone = gone_routers([{"name": m.router_name, "latest": m} for m in newest], scrape)
|
||||
|
||||
b_secs = bucket_seconds(since)
|
||||
bucket_col = _time_bucket(TraefikMetric.scraped_at, b_secs)
|
||||
|
||||
router_data = []
|
||||
for router in routers:
|
||||
# Bucketed history for sparklines — always ~80 points regardless of range
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.avg(TraefikMetric.request_rate).label("request_rate"),
|
||||
func.avg(TraefikMetric.latency_p95_ms).label("latency_p95_ms"),
|
||||
func.avg(TraefikMetric.error_rate_5xx_pct).label("error_rate_5xx_pct"),
|
||||
func.min(TraefikMetric.scraped_at).label("scraped_at"),
|
||||
bucket_col,
|
||||
)
|
||||
.where(TraefikMetric.router_name == router)
|
||||
.where(TraefikMetric.scraped_at >= since)
|
||||
.group_by(bucket_col)
|
||||
.order_by(bucket_col)
|
||||
)
|
||||
history = result.all()
|
||||
|
||||
latest = newest[router]
|
||||
|
||||
state, why = router_state(latest, thresholds)
|
||||
router_data.append({
|
||||
"name": router,
|
||||
"latest": latest,
|
||||
"state": state,
|
||||
"why": why,
|
||||
"sparkline_req": _sparkline([r.request_rate for r in history]),
|
||||
"sparkline_p95": _sparkline([r.latency_p95_ms for r in history]),
|
||||
"sparkline_5xx": _sparkline([r.error_rate_5xx_pct for r in history]),
|
||||
})
|
||||
history = await router_history(db, [r["name"] for r in current], since, bucket_seconds(since))
|
||||
for r in current:
|
||||
points = history[r["name"]]
|
||||
r["state"], r["why"] = router_state(r["latest"], thresholds)
|
||||
r["sparkline_req"] = _sparkline([p.request_rate for p in points])
|
||||
r["sparkline_p95"] = _sparkline([p.latency_p95_ms for p in points])
|
||||
r["sparkline_5xx"] = _sparkline([p.error_rate_5xx_pct for p in points])
|
||||
|
||||
# Latest global stat (always most recent, not range-filtered)
|
||||
result = await db.execute(
|
||||
@@ -233,8 +210,6 @@ async def rows():
|
||||
)
|
||||
raw_certs = result.scalars().all()
|
||||
|
||||
scrape = current_app.config["PLUGINS"]["traefik"]["scrape_interval_seconds"]
|
||||
current, gone = gone_routers(router_data, scrape)
|
||||
served, old = current_certs(raw_certs, scrape)
|
||||
domains = router_domains()
|
||||
|
||||
@@ -382,6 +357,34 @@ def _time_bucket(column, seconds: int):
|
||||
func.to_timestamp(0)).label("bucket")
|
||||
|
||||
|
||||
async def router_history(db, names: list[str], since, bucket_secs: int) -> dict[str, list]:
|
||||
"""{router: [rows of request_rate, latency_p95_ms, error_rate_5xx_pct]}
|
||||
per `bucket_secs` window since `since`, oldest first: one query for every
|
||||
router, answered from the covering (router_name, scraped_at) index
|
||||
(traefik_004). It was a query per router, each row a heap read, which
|
||||
took the rows page to 12-28 s."""
|
||||
history: dict[str, list] = {n: [] for n in names}
|
||||
if not history:
|
||||
return history
|
||||
bucket_col = _time_bucket(TraefikMetric.scraped_at, bucket_secs)
|
||||
result = await db.execute(
|
||||
select(
|
||||
TraefikMetric.router_name,
|
||||
func.avg(TraefikMetric.request_rate).label("request_rate"),
|
||||
func.avg(TraefikMetric.latency_p95_ms).label("latency_p95_ms"),
|
||||
func.avg(TraefikMetric.error_rate_5xx_pct).label("error_rate_5xx_pct"),
|
||||
bucket_col,
|
||||
)
|
||||
.where(TraefikMetric.router_name.in_(names))
|
||||
.where(TraefikMetric.scraped_at >= since)
|
||||
.group_by(TraefikMetric.router_name, bucket_col)
|
||||
.order_by(TraefikMetric.router_name, bucket_col)
|
||||
)
|
||||
for row in result.all():
|
||||
history[row.router_name].append(row)
|
||||
return history
|
||||
|
||||
|
||||
async def request_history(db, since, bucket_seconds: int) -> list[dict]:
|
||||
"""[{"at", "req_rate", "err_pct"}] per time bucket since `since`: each
|
||||
scrape summed over routers (the total req/s), averaged over the bucket;
|
||||
|
||||
Reference in New Issue
Block a user