Files
shopdb-flask/plugins/backups/migrations/versions/0002_backups_lastseenat.py
cproudlock 6c975a107c
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s
backups: record that a config was checked, not only that it changed
The stale-backup card could not be built as designed, and the reason is more
important than the card. Dedup means an unchanged configuration writes no
revision, so collectedat moves only on a CHANGE. A machine stable for six
months has a six-month-old newest revision and is perfectly healthy. Keying a
staleness card on revision age would have flagged most of the fleet - exactly
the noise that makes a board worth ignoring.

Underneath that: ShopDB could not distinguish those cases at all. On a no-op
the server returned "unchanged" and wrote nothing, so "we checked yesterday and
it matched" was discarded. That fact is the one thing a backup system must be
able to prove, and the only record of it was a line in a log file on the PC.

lastseenat records the check rather than the change. Touched on every matching
post including the no-op; set on creation, since a new revision has by
definition just been seen; backfilled from collectedat or createdat so existing
rows start from the last moment the config can be PROVEN current, rather than
from now - claiming a check that never happened would be worse than silence.

The card keys on it, one row per CHAIN rather than per asset: a machine with
two part markers can have one still reporting while the other stopped, and a
per-asset view would report the machine as fine. It stays deliberately silent
about assets never backed up, because whether one SHOULD be is a question only
the manifest can answer, and guessing would list a hundred healthy machines.

The rule lives in services/staleness.py rather than the route, so it is
testable without an auth layer in the way - the same split retention.py uses.

Threshold is backups_staledays, default 3, and 0 disables the card.
2026-08-11 13:52:07 -04:00

44 lines
1.6 KiB
Python

"""backups: record when a configuration was last confirmed unchanged.
Dedup means an unchanged config writes NO revision, so `collectedat` moves only
when something changes. A machine whose settings have been stable for six months
therefore has a six-month-old newest revision and is entirely healthy - and
ShopDB had no way to tell it apart from a machine whose backup stopped running
six months ago. The evidence that a check happened existed only in a log file on
the PC.
`lastseenat` records the check rather than the change. It is touched on every
matching post, including the no-op that writes nothing else.
Backfilled from `collectedat` (falling back to `createdat`) so existing rows
start from the last moment we can actually prove the config was current, rather
than from now - claiming a fresh check that never happened would be worse than
saying nothing.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'backups0002lastseenat'
down_revision = 'backups0001baseline'
branch_labels = None
depends_on = None
def upgrade():
columns = {c['name'] for c in
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
if 'lastseenat' not in columns:
op.add_column('backuprevisions',
sa.Column('lastseenat', sa.DateTime(), nullable=True))
op.execute('UPDATE backuprevisions '
'SET lastseenat = COALESCE(collectedat, createdat)')
def downgrade():
columns = {c['name'] for c in
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
if 'lastseenat' in columns:
op.drop_column('backuprevisions', 'lastseenat')