fix(syslog): a device through Docker's address stays one source when that address changes (#6717)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m45s
CI / publish (push) Successful in 1m24s

Docker's ingress address depends on the node Steward's task is on, and sources
were keyed by (address, sender). After a task move the same device arrived under
a new key and became a new "offered" row, so its lines were counted, not kept.

A named sender is now one row whatever address it last came from, enforced by a
partial unique index. Migration syslog_005 folds rows sharing a name into the
added one and moves their lines and patterns across.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 14:02:04 -04:00
co-authored by Claude Opus 5.5
parent 57b2aafd1a
commit 50b8922895
5 changed files with 150 additions and 16 deletions
@@ -0,0 +1,64 @@
"""Syslog: one row per name at Docker's address, whichever address it last came
from. Rows sharing a name fold into the added one, or else the newest.
Revision ID: syslog_005_one_row_per_sender
Revises: syslog_004_device_address
Create Date: 2026-10-30
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "syslog_005_one_row_per_sender"
down_revision: Union[str, None] = "syslog_004_device_address"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_STATE_FIRST = {"accepted": 0, "ignored": 1, "offered": 2}
def _fold(bind, sender: str) -> None:
rows = bind.execute(sa.text(
"SELECT id, address, state, device_address, host_id, hostname_seen, first_seen_at, last_seen_at, "
"lines_total, last_severity, last_line FROM syslog_sources WHERE sender = :sender"
), {"sender": sender}).mappings().all()
keep = min(rows, key=lambda r: (_STATE_FIRST.get(r["state"], 3), -r["last_seen_at"].timestamp()))
newest = max(rows, key=lambda r: r["last_seen_at"])
others = [r["id"] for r in rows if r["id"] != keep["id"]]
in_others = sa.bindparam("others", expanding=True)
for table in ("syslog_lines", "syslog_patterns"):
bind.execute(sa.text(f"UPDATE {table} SET source_id = :keep WHERE source_id IN :others").bindparams(in_others),
{"keep": keep["id"], "others": others})
bind.execute(sa.text("DELETE FROM syslog_sources WHERE id IN :others").bindparams(in_others), {"others": others})
bind.execute(sa.text(
"UPDATE syslog_sources SET address = :address, device_address = :device_address, host_id = :host_id, "
"hostname_seen = :hostname_seen, first_seen_at = :first_seen_at, last_seen_at = :last_seen_at, "
"lines_total = :lines_total, last_severity = :last_severity, last_line = :last_line WHERE id = :id"
), {
"id": keep["id"],
"address": newest["address"],
"device_address": next((r["device_address"] for r in rows if r["device_address"]), ""),
"host_id": keep["host_id"] or next((r["host_id"] for r in rows if r["host_id"]), None),
"hostname_seen": newest["hostname_seen"],
"first_seen_at": min(r["first_seen_at"] for r in rows),
"last_seen_at": newest["last_seen_at"],
"lines_total": sum(r["lines_total"] or 0 for r in rows),
"last_severity": newest["last_severity"],
"last_line": newest["last_line"],
})
def upgrade() -> None:
bind = op.get_bind()
senders = bind.execute(sa.text(
"SELECT sender FROM syslog_sources WHERE sender <> '' GROUP BY sender HAVING count(*) > 1"
)).scalars().all()
for sender in senders:
_fold(bind, sender)
op.create_index("uq_syslog_sources_sender", "syslog_sources", ["sender"], unique=True,
postgresql_where=sa.text("sender <> ''"))
def downgrade() -> None:
op.drop_index("uq_syslog_sources_sender", table_name="syslog_sources")