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
+30 -13
View File
@@ -23,7 +23,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from steward.core.scheduler import ScheduledTask
@@ -246,21 +246,36 @@ async def store_lines(session, lines: list[Received]) -> None:
for line in lines:
by_sender.setdefault(sender_of(line), []).append(line)
addresses = list({address for address, _ in by_sender})
sources = {(s.address, s.sender): s for s in (await session.execute(
select(SyslogSource).where(SyslogSource.address.in_(addresses)))).scalars()}
new = [k for k in by_sender if k not in sources]
for key in [k for k in new if k[1]]:
# A row from before devices at Docker's address were told apart by
# name, last seen under this one, is this device: it keeps its state
# and name rather than a new row being offered beside it.
before = sources.get((key[0], ""))
if before is not None and before.hostname_seen == key[1]:
before.sender = key[1]
sources[key] = sources.pop((key[0], ""))
new.remove(key)
names = list({name for _, name in by_sender if name})
rows = (await session.execute(select(SyslogSource).where(
or_(SyslogSource.address.in_(addresses), SyslogSource.sender.in_(names))))).scalars().all()
sources = {(s.address, s.sender): s for s in rows}
by_name = {s.sender: s for s in rows if s.sender}
new = []
for key in by_sender:
address, name = key
if name in by_name:
# Docker's address depends on the node Steward's task is on, so a
# device is its name wherever it now arrives from.
by_name[name].address = address
sources[key] = by_name[name]
elif key not in sources:
# A row from before devices at Docker's address were told apart by
# name, last seen under this one, is this device: it keeps its state
# and name rather than a new row being offered beside it.
before = sources.get((address, ""))
if name and before is not None and before.hostname_seen == name:
before.sender = name
by_name[name] = sources[key] = sources.pop((address, ""))
else:
new.append(key)
if new:
known = (await session.execute(select(func.count()).select_from(SyslogSource))).scalar_one()
for key in new:
if key[1] in by_name: # the same name from a second address in this round
by_name[key[1]].address = key[0]
sources[key] = by_name[key[1]]
continue
if known >= MAX_SENDERS:
LISTENER.unoffered += len(by_sender.pop(key))
continue
@@ -268,6 +283,8 @@ async def store_lines(session, lines: list[Received]) -> None:
source = SyslogSource(address=key[0], sender=key[1], first_seen_at=first.at, last_seen_at=first.at)
session.add(source)
sources[key] = source
if key[1]:
by_name[key[1]] = source
known += 1
await session.flush()