migrations: commit the plugin chains too, and bound what re-ran

Core's Alembic env got connection.commit() when the stamp bug was found; the
per-plugin template did not. MySQL commits DDL implicitly, which flushes
everything queued before it including the previous migration's version stamp,
and the LAST migration of a run has no DDL after it - so its stamp rolled back
at close while its schema change survived. flask plugin upgrade-all then exited
0 having silently re-run that migration, and re-ran it again on every deploy
after. Invisible for exactly as long as every plugin head happened to be
idempotent.

Two were not.

backups 0003 cleared lastseenat for EVERY row, which is correct once and
destroys evidence on each repeat. It is now scoped to the backfill's actual
signature, COALESCE(collectedat, createdat) - the expression 0002 wrote - plus a
date bound. Both conditions are needed. Matching on collectedat alone misses
every row whose collectedat is NULL, so precisely the rows carrying the most
invented value would have kept it forever; and value equality is not a signature
on MySQL, where db.DateTime is second-precision and the collector writes both
stamps in one statement, so a genuinely fresh revision would read as a backfill
and be wiped. SQLite keeps microseconds, which is why no test could show it.

geenforce 0003 added a column unconditionally, so it failed on a fresh database
built from the models and on any re-run. Guarded like network0003prefix.
This commit is contained in:
cproudlock
2026-08-14 13:46:23 -04:00
parent 0c574e0f49
commit 38deefe619
3 changed files with 58 additions and 1 deletions

View File

@@ -30,7 +30,34 @@ def upgrade():
columns = {c['name'] for c in columns = {c['name'] for c in
sa.inspect(op.get_bind()).get_columns('backuprevisions')} sa.inspect(op.get_bind()).get_columns('backuprevisions')}
if 'lastseenat' in columns: if 'lastseenat' in columns:
op.execute('UPDATE backuprevisions SET lastseenat = NULL') # SCOPED TO THE BACKFILL, not to every row. An unscoped UPDATE was
# correct exactly once; re-running it destroys evidence, and this chain
# HAS been re-running on MySQL (see the missing commit in
# shopdb/plugins/alembic_template.py). A migration that deletes data has
# to assume it will run twice.
#
# TWO CONDITIONS, and both are needed:
#
# 1. The backfill's actual signature is COALESCE(collectedat, createdat)
# - that is the expression 0002 wrote. Matching `lastseenat =
# collectedat` alone misses every row whose collectedat is NULL,
# because NULL comparison is never true, so exactly the rows carrying
# the most invented value would have kept it forever.
#
# 2. A date bound, because value equality on its own is not a signature
# on MySQL. db.DateTime is second-precision there (SQLite keeps
# microseconds, which is why no test could show this), and the
# collector writes collectedat and lastseenat in one statement - so a
# genuinely fresh revision that arrived without a payload collectedat
# has the two stamps equal to the second and would be read as a
# backfill and wiped.
#
# The cutoff is the day this correction ships. Everything the backfill
# touched predates it by construction; anything created after it is a
# live collection this migration must never touch.
op.execute("UPDATE backuprevisions SET lastseenat = NULL "
"WHERE lastseenat = COALESCE(collectedat, createdat) "
"AND createdat < '2026-08-14 00:00:00'")
def downgrade(): def downgrade():

View File

@@ -19,10 +19,28 @@ branch_labels = None
depends_on = None depends_on = None
def _columns(bind):
insp = sa.inspect(bind)
if 'manifestenforcementreports' not in insp.get_table_names():
return None
return {c['name'] for c in insp.get_columns('manifestenforcementreports')}
def upgrade(): def upgrade():
# Guarded, like network0003prefix. On a FRESH database the table is built
# from the SQLAlchemy models, which already declare this column, so an
# unconditional add fails with "duplicate column name" - and this chain has
# been re-running its head on MySQL (see the missing commit in
# shopdb/plugins/alembic_template.py), so a redeploy hit the same wall.
columns = _columns(op.get_bind())
if columns is None or 'subtype' in columns:
return
op.add_column('manifestenforcementreports', op.add_column('manifestenforcementreports',
sa.Column('subtype', sa.String(length=50), nullable=True)) sa.Column('subtype', sa.String(length=50), nullable=True))
def downgrade(): def downgrade():
columns = _columns(op.get_bind())
if columns is None or 'subtype' not in columns:
return
op.drop_column('manifestenforcementreports', 'subtype') op.drop_column('manifestenforcementreports', 'subtype')

View File

@@ -180,3 +180,15 @@ def run_migrations():
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
# SAME FIX AS migrations/env.py. MySQL DDL commits implicitly, which
# flushes everything queued before it - including the PREVIOUS
# migration's version stamp. The LAST migration of a run has no DDL
# after it, so its stamp is rolled back when the connection closes:
# the schema change survives, alembic_version stays one revision
# behind, and `flask plugin upgrade-all` exits 0 having silently
# re-run that migration - again on every deploy after.
#
# Core got this fix; the per-plugin chains run through THIS file and
# did not, so every plugin head has been re-running on MySQL. That is
# only invisible while every head happens to be idempotent.
connection.commit()