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)
|
now = datetime.now(timezone.utc)
|
||||||
thresholds = current_app.config.get("THRESHOLDS", {})
|
thresholds = current_app.config.get("THRESHOLDS", {})
|
||||||
|
|
||||||
|
scrape = current_app.config["PLUGINS"]["traefik"]["scrape_interval_seconds"]
|
||||||
|
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
# Every known router, with its most recent raw row
|
# Every known router, with its most recent raw row; a router Traefik
|
||||||
newest = {m.router_name: m for m in await latest_per_router(db)}
|
# stopped reporting gets no sparklines, so its history is not read.
|
||||||
routers = sorted(newest)
|
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)
|
history = await router_history(db, [r["name"] for r in current], since, bucket_seconds(since))
|
||||||
bucket_col = _time_bucket(TraefikMetric.scraped_at, b_secs)
|
for r in current:
|
||||||
|
points = history[r["name"]]
|
||||||
router_data = []
|
r["state"], r["why"] = router_state(r["latest"], thresholds)
|
||||||
for router in routers:
|
r["sparkline_req"] = _sparkline([p.request_rate for p in points])
|
||||||
# Bucketed history for sparklines — always ~80 points regardless of range
|
r["sparkline_p95"] = _sparkline([p.latency_p95_ms for p in points])
|
||||||
result = await db.execute(
|
r["sparkline_5xx"] = _sparkline([p.error_rate_5xx_pct for p in points])
|
||||||
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]),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Latest global stat (always most recent, not range-filtered)
|
# Latest global stat (always most recent, not range-filtered)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -233,8 +210,6 @@ async def rows():
|
|||||||
)
|
)
|
||||||
raw_certs = result.scalars().all()
|
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)
|
served, old = current_certs(raw_certs, scrape)
|
||||||
domains = router_domains()
|
domains = router_domains()
|
||||||
|
|
||||||
@@ -382,6 +357,34 @@ def _time_bucket(column, seconds: int):
|
|||||||
func.to_timestamp(0)).label("bucket")
|
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]:
|
async def request_history(db, since, bucket_seconds: int) -> list[dict]:
|
||||||
"""[{"at", "req_rate", "err_pct"}] per time bucket since `since`: each
|
"""[{"at", "req_rate", "err_pct"}] per time bucket since `since`: each
|
||||||
scrape summed over routers (the total req/s), averaged over the bucket;
|
scrape summed over routers (the total req/s), averaged over the bucket;
|
||||||
|
|||||||
@@ -74,3 +74,37 @@ def test_the_request_chart_sums_routers_and_weights_the_5xx_share(app):
|
|||||||
got = asyncio.run(go())
|
got = asyncio.run(go())
|
||||||
# One bucket: totals 4 and 6 average to 5; 150 weighted 5xx over 10 req/s is 15%.
|
# One bucket: totals 4 and 6 average to 5; 150 weighted 5xx over 10 req/s is 15%.
|
||||||
assert got == [{"at": at, "req_rate": 5.0, "err_pct": 15.0}]
|
assert got == [{"at": at, "req_rate": 5.0, "err_pct": 15.0}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_history_reads_every_router_in_one_query_bucketed(app):
|
||||||
|
"""#5214: the rows page read each router's history in its own query, a
|
||||||
|
heap fetch per row, and took 12-28 s. One query now buckets every named
|
||||||
|
router; a router not asked for is not read, and one with no rows in the
|
||||||
|
range still has an (empty) entry."""
|
||||||
|
from plugins.traefik.models import TraefikMetric
|
||||||
|
from plugins.traefik.routes import router_history
|
||||||
|
|
||||||
|
at = datetime(2098, 6, 1, 12, 0, tzinfo=timezone.utc) + timedelta(minutes=5 * (uuid.uuid4().int % 2_000))
|
||||||
|
tag = uuid.uuid4().hex[:8]
|
||||||
|
a, b, c, idle = (f"{x}-{tag}@docker" for x in ("a", "b", "c", "idle"))
|
||||||
|
|
||||||
|
def row(name, minute, rate, p95=2.0):
|
||||||
|
return TraefikMetric(router_name=name, scraped_at=at + timedelta(minutes=minute), request_rate=rate,
|
||||||
|
error_rate_4xx_pct=0.0, error_rate_5xx_pct=0.0, latency_p50_ms=1.0,
|
||||||
|
latency_p95_ms=p95, latency_p99_ms=3.0, response_bytes_rate=0.0)
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
async with app.db_sessionmaker() as s:
|
||||||
|
async with s.begin():
|
||||||
|
s.add_all([row(a, 0, 1.0, 10.0), row(a, 1, 3.0, 30.0), row(a, 5, 7.0),
|
||||||
|
row(b, 0, 4.0), row(c, 0, 9.0), row(idle, -60, 5.0)])
|
||||||
|
async with s.begin():
|
||||||
|
return await router_history(s, [a, b, idle], at, 300)
|
||||||
|
|
||||||
|
got = asyncio.run(go())
|
||||||
|
assert set(got) == {a, b, idle} # c was not asked for
|
||||||
|
# a: minutes 0 and 1 share the first 5-minute bucket and average; minute 5 starts the next.
|
||||||
|
assert [r.request_rate for r in got[a]] == [2.0, 7.0]
|
||||||
|
assert got[a][0].latency_p95_ms == 20.0
|
||||||
|
assert [r.request_rate for r in got[b]] == [4.0]
|
||||||
|
assert got[idle] == [] # its only row is before the range
|
||||||
|
|||||||
Reference in New Issue
Block a user