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:
@@ -30,7 +30,34 @@ def upgrade():
|
||||
columns = {c['name'] for c in
|
||||
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
|
||||
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():
|
||||
|
||||
Reference in New Issue
Block a user