fix(metrics): time-series rows get time-ordered ids, so an insert stops costing a disk read (#5149)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 45s
CI / unit (push) Successful in 1m10s
CI / publish (push) Successful in 54s

Measured on dev (385a579), agent posts cut to five samples still dropped:
flush=8.8s for 990 rows, 6.6s for 680, 8.4s for 1000 — about 110 rows/s,
~9 ms a row, with rules and evaluation near zero. One random read per
row fits a random uuid4 primary key whose index no longer fits in memory:
every new row lands on a random page of it. (Inferred from the rate; the
table and index sizes have not been read.)

- steward.core.ids.time_ordered_id: a UUIDv7-layout string (48-bit ms
  timestamp, then random), same 36-char shape as the stored uuid4 ids, so
  no migration. Python's uuid7() arrives in 3.14; the image runs 3.13.
- Used for plugin_metrics and its hourly rollup, monitor_results,
  audit_events, the docker metrics/hourly/events/logs tables, and the
  traefik and unifi time series.
- MAX_SAMPLES_PER_INGEST 5 → 2 (~400 rows) for now: it fits the agent's
  10 s even at the old rate, so hosts land data whichever way the new rate
  comes out. Raise it once the new rate is measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 18:58:41 -04:00
co-authored by Claude Opus 5.5
parent ade078cabf
commit ad4f7d9232
9 changed files with 83 additions and 21 deletions
+5 -5
View File
@@ -1,6 +1,5 @@
# plugins/docker/models.py # plugins/docker/models.py
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import ( from sqlalchemy import (
BigInteger, Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text, BigInteger, Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text,
@@ -8,6 +7,7 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from steward.models.base import Base from steward.models.base import Base
from steward.core.ids import time_ordered_id
class DockerContainer(Base): class DockerContainer(Base):
@@ -67,7 +67,7 @@ class DockerMetric(Base):
__tablename__ = "docker_metrics" __tablename__ = "docker_metrics"
id: Mapped[str] = mapped_column( id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4()) String(36), primary_key=True, default=time_ordered_id
) )
host_id: Mapped[str] = mapped_column( host_id: Mapped[str] = mapped_column(
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False, index=True String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False, index=True
@@ -103,7 +103,7 @@ class DockerMetricHourly(Base):
__tablename__ = "docker_metrics_hourly" __tablename__ = "docker_metrics_hourly"
id: Mapped[str] = mapped_column( id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4()) String(36), primary_key=True, default=time_ordered_id
) )
host_id: Mapped[str] = mapped_column( host_id: Mapped[str] = mapped_column(
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False
@@ -139,7 +139,7 @@ class DockerEvent(Base):
__tablename__ = "docker_events" __tablename__ = "docker_events"
id: Mapped[str] = mapped_column( id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4()) String(36), primary_key=True, default=time_ordered_id
) )
host_id: Mapped[str] = mapped_column( host_id: Mapped[str] = mapped_column(
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False
@@ -176,7 +176,7 @@ class DockerLog(Base):
__tablename__ = "docker_logs" __tablename__ = "docker_logs"
id: Mapped[str] = mapped_column( id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4()) String(36), primary_key=True, default=time_ordered_id
) )
host_id: Mapped[str] = mapped_column( host_id: Mapped[str] = mapped_column(
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False, index=True String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False, index=True
+3 -1
View File
@@ -343,7 +343,9 @@ SLOW_INGEST_SECONDS = 2.0
# instance that did not finish inside the agent's 10 s, the rollback left the # instance that did not finish inside the agent's 10 s, the rollback left the
# buffer full, and every later post failed the same way. The newest samples # buffer full, and every later post failed the same way. The newest samples
# are kept: the older ones would be lost to the agent's ring buffer anyway. # are kept: the older ones would be lost to the agent's ring buffer anyway.
MAX_SAMPLES_PER_INGEST = 5 # Two (~400 rows) fits 10 s even at the ~110 rows/s measured before
# plugin_metrics got time-ordered ids; raise it once the new rate is known.
MAX_SAMPLES_PER_INGEST = 2
def newest_samples(samples: list, limit: int) -> tuple[list, int]: def newest_samples(samples: list, limit: int) -> tuple[list, int]:
+4 -4
View File
@@ -1,16 +1,16 @@
# plugins/traefik/models.py # plugins/traefik/models.py
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, Integer, String, Text from sqlalchemy import DateTime, Float, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from steward.models.base import Base from steward.models.base import Base
from steward.core.ids import time_ordered_id
class TraefikMetric(Base): class TraefikMetric(Base):
__tablename__ = "traefik_metrics" __tablename__ = "traefik_metrics"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
router_name: Mapped[str] = mapped_column(String(255), nullable=False) router_name: Mapped[str] = mapped_column(String(255), nullable=False)
scraped_at: Mapped[datetime] = mapped_column( scraped_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
@@ -41,7 +41,7 @@ class TraefikAccessSummary(Base):
"""Aggregated traffic-origin summary — one row per scheduler tick.""" """Aggregated traffic-origin summary — one row per scheduler tick."""
__tablename__ = "traefik_access_summaries" __tablename__ = "traefik_access_summaries"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
period_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) period_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
total_requests: Mapped[int] = mapped_column(Integer, nullable=False, default=0) total_requests: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
@@ -57,7 +57,7 @@ class TraefikGlobalStat(Base):
"""One row per scrape — process-level and config stats.""" """One row per scrape — process-level and config stats."""
__tablename__ = "traefik_global_stats" __tablename__ = "traefik_global_stats"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
scraped_at: Mapped[datetime] = mapped_column( scraped_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
) )
+5 -5
View File
@@ -1,10 +1,10 @@
# plugins/unifi/models.py # plugins/unifi/models.py
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from steward.models.base import Base from steward.models.base import Base
from steward.core.ids import time_ordered_id
# ── Time-series (one row per scrape) ────────────────────────────────────────── # ── Time-series (one row per scrape) ──────────────────────────────────────────
@@ -13,7 +13,7 @@ class UnifiWanStat(Base):
"""WAN interface health — one row per scrape.""" """WAN interface health — one row per scrape."""
__tablename__ = "unifi_wan_stats" __tablename__ = "unifi_wan_stats"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
is_up: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_up: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
wan_ip: Mapped[str | None] = mapped_column(String(64), nullable=True) wan_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
@@ -27,7 +27,7 @@ class UnifiClientSnapshot(Base):
"""Aggregated client counts + top talkers — one row per scrape.""" """Aggregated client counts + top talkers — one row per scrape."""
__tablename__ = "unifi_client_snapshots" __tablename__ = "unifi_client_snapshots"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
total_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=0) total_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
wireless_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=0) wireless_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
@@ -41,7 +41,7 @@ class UnifiWlanStat(Base):
"""Per-SSID/radio stats — one row per scrape per SSID+radio combination.""" """Per-SSID/radio stats — one row per scrape per SSID+radio combination."""
__tablename__ = "unifi_wlan_stats" __tablename__ = "unifi_wlan_stats"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
ssid: Mapped[str] = mapped_column(String(255), nullable=False) ssid: Mapped[str] = mapped_column(String(255), nullable=False)
radio: Mapped[str | None] = mapped_column(String(8), nullable=True) # ng=2.4GHz, na=5GHz, 6e=6GHz radio: Mapped[str | None] = mapped_column(String(8), nullable=True) # ng=2.4GHz, na=5GHz, 6e=6GHz
@@ -56,7 +56,7 @@ class UnifiDpiSnapshot(Base):
"""DPI application/category traffic snapshot — one row per scrape (JSON blob).""" """DPI application/category traffic snapshot — one row per scrape (JSON blob)."""
__tablename__ = "unifi_dpi_snapshots" __tablename__ = "unifi_dpi_snapshots"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
# JSON: [{"cat_id", "cat_name", "rx_bytes", "tx_bytes"}] aggregated across all clients # JSON: [{"cat_id", "cat_name", "rx_bytes", "tx_bytes"}] aggregated across all clients
by_category_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") by_category_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
+25
View File
@@ -0,0 +1,25 @@
"""Row ids for tables that grow by time.
A random uuid4 primary key puts every new row on a random page of the key's
index; once that index outgrows memory, each insert waits for a disk read.
Measured on the live instance (#5149): plugin_metrics took ~9 ms a row, about
110 rows/s, so a host agent's post could not finish inside its 10 s timeout.
A time-ordered id lands every new row at the index's right edge instead.
"""
from __future__ import annotations
import os
import time
import uuid
def time_ordered_id() -> str:
"""A UUIDv7-layout id (RFC 9562) as a 36-character string: a 48-bit
millisecond timestamp, then 74 random bits. Same shape and column type as
the uuid4 ids already stored, so no table changes; Python's own uuid7()
arrives only in 3.14."""
ms = time.time_ns() // 1_000_000
value = (ms & 0xFFFF_FFFF_FFFF) << 80 | int.from_bytes(os.urandom(10), "big")
value = (value & ~(0xF << 76)) | (0x7 << 76) # version 7
value = (value & ~(0x3 << 62)) | (0x2 << 62) # RFC 9562 variant
return str(uuid.UUID(int=value))
+2 -2
View File
@@ -1,15 +1,15 @@
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
from steward.core.ids import time_ordered_id
class AuditEvent(Base): class AuditEvent(Base):
__tablename__ = "audit_events" __tablename__ = "audit_events"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
timestamp: Mapped[datetime] = mapped_column( timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
) )
+3 -3
View File
@@ -1,15 +1,15 @@
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, Index, Integer, String, UniqueConstraint from sqlalchemy import DateTime, Float, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
from steward.core.ids import time_ordered_id
class PluginMetric(Base): class PluginMetric(Base):
__tablename__ = "plugin_metrics" __tablename__ = "plugin_metrics"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
source_module: Mapped[str] = mapped_column(String(64), nullable=False) source_module: Mapped[str] = mapped_column(String(64), nullable=False)
resource_name: Mapped[str] = mapped_column(String(255), nullable=False) resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
metric_name: Mapped[str] = mapped_column(String(128), nullable=False) metric_name: Mapped[str] = mapped_column(String(128), nullable=False)
@@ -40,7 +40,7 @@ class PluginMetricHourly(Base):
""" """
__tablename__ = "plugin_metrics_hourly" __tablename__ = "plugin_metrics_hourly"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
source_module: Mapped[str] = mapped_column(String(64), nullable=False) source_module: Mapped[str] = mapped_column(String(64), nullable=False)
resource_name: Mapped[str] = mapped_column(String(255), nullable=False) resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
metric_name: Mapped[str] = mapped_column(String(128), nullable=False) metric_name: Mapped[str] = mapped_column(String(128), nullable=False)
+2 -1
View File
@@ -8,6 +8,7 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
from steward.core.ids import time_ordered_id
class MonitorType(str, enum.Enum): class MonitorType(str, enum.Enum):
@@ -93,7 +94,7 @@ class MonitorResult(Base):
__tablename__ = "monitor_results" __tablename__ = "monitor_results"
id: Mapped[str] = mapped_column( id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4()) String(36), primary_key=True, default=time_ordered_id
) )
monitor_id: Mapped[str] = mapped_column( monitor_id: Mapped[str] = mapped_column(
String(36), ForeignKey("monitors.id", ondelete="CASCADE"), String(36), ForeignKey("monitors.id", ondelete="CASCADE"),
+34
View File
@@ -0,0 +1,34 @@
"""Time-series rows get time-ordered ids, so a new row lands at the right edge
of the primary-key index rather than on a random page of it (#5149)."""
import uuid
from steward.core import ids
from steward.core.ids import time_ordered_id
from steward.models.metrics import PluginMetric
def test_the_id_is_a_version_7_uuid_string():
raw = time_ordered_id()
u = uuid.UUID(raw)
assert len(raw) == 36 and str(u) == raw
assert u.version == 7 and u.variant == uuid.RFC_4122
def test_ids_sort_by_the_millisecond_they_were_made(monkeypatch):
made = []
for ms in (1_791_327_000_000, 1_791_327_000_001, 1_791_327_060_000):
monkeypatch.setattr(ids.time, "time_ns", lambda ms=ms: ms * 1_000_000)
made.append(time_ordered_id())
assert made == sorted(made)
assert len({m[:13] for m in made}) == 3 # the timestamp leads the string
def test_two_ids_in_one_millisecond_differ(monkeypatch):
monkeypatch.setattr(ids.time, "time_ns", lambda: 1_791_327_000_000 * 1_000_000)
assert time_ordered_id() != time_ordered_id()
def test_plugin_metrics_use_it():
# SQLAlchemy wraps a no-argument default as fn(context); call it as it would.
made = PluginMetric.__table__.c.id.default.arg(None)
assert uuid.UUID(made).version == 7