ADR-014 Phase 2: flask plugin prune-schema for lean per-site DBs
A lean site still gets every plugin's tables from the shared core Alembic baseline. prune-schema drops the tables of plugins not installed on this site, leaving core + chosen-plugin tables, with no edit to any released migration (the relocate-into-plugin-baselines alternative would mean rewriting ~15 released core migrations for a cosmetic gain - see ADR-014). - shopdb/plugins/cli.py: prune-schema command. Dry-run by default; --yes to execute; refuses non-empty tables without --force. Drops by table name (no plugin import) so it works on a lean image. MySQL: private AUTOCOMMIT engine (db.engine's pooled connections sit idle-in-transaction in a CLI context and would deadlock the DROP on a metadata lock). SQLite: db.engine, restoring the prior foreign_keys pragma so the StaticPool connection is not left changed. - tests/test_plugin_prune_schema.py: drop-only-not-installed, full no-op, refuse-non-empty, force-drops-non-empty. - docs/DEPLOY.md: lean provisioning step after upgrade-all. - ADR-014 ACCEPTED; index updated. Verified on MySQL: full install then prune = no-op (86 tables); lean install (machines+printers) then prune drops the other 19 plugin tables; second run no-op. Full suite 1077 passed.
This commit is contained in:
@@ -886,3 +886,142 @@ def upgrade_all_plugins():
|
||||
click.echo(click.style(f" {name:20} no migrations", fg='yellow'))
|
||||
else:
|
||||
click.echo(click.style(f" {name:20} {status}", fg='red'))
|
||||
|
||||
|
||||
@plugin_cli.command('prune-schema')
|
||||
@click.option('--yes', is_flag=True,
|
||||
help='Actually drop the tables (default is a dry-run preview)')
|
||||
@click.option('--force', is_flag=True,
|
||||
help='Drop even tables that hold rows (DATA LOSS); default refuses')
|
||||
@with_appcontext
|
||||
def prune_schema(yes: bool, force: bool):
|
||||
"""Drop tables owned by plugins this site did NOT install (ADR-014).
|
||||
|
||||
Schema-lean per-site DBs: the shared core Alembic baseline creates every
|
||||
plugin's tables, so a lean site that omits a plugin still carries that
|
||||
plugin's (empty) tables. This drops the tables of every plugin in
|
||||
PLUGIN_TABLE_OWNERS that is not installed here, leaving core + chosen-plugin
|
||||
tables only. Run once at deploy AFTER `flask db upgrade` and
|
||||
`flask plugin upgrade-all`.
|
||||
|
||||
Dry-run by default; pass --yes to execute. Drops by table name (no plugin
|
||||
code import) so it works on a lean image where the omitted plugin's
|
||||
directory is absent. Refuses to drop a non-empty table unless --force, so a
|
||||
misfire on a populated full site cannot silently delete data.
|
||||
"""
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.plugins.alembic_template import PLUGIN_TABLE_OWNERS
|
||||
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
installed = {p['name'] for p in pm.discover_available() if p['installed']}
|
||||
engine = db.engine
|
||||
existing = set(inspect(engine).get_table_names())
|
||||
|
||||
# A table is a prune candidate when its owning plugin is not installed here
|
||||
# AND the table actually exists in this database.
|
||||
victims = []
|
||||
for plugin, tables in PLUGIN_TABLE_OWNERS.items():
|
||||
if plugin in installed:
|
||||
continue
|
||||
for tablename in tables:
|
||||
if tablename in existing:
|
||||
victims.append((plugin, tablename))
|
||||
|
||||
if not victims:
|
||||
click.echo("Nothing to prune: every plugin-owned table belongs to an "
|
||||
"installed plugin.")
|
||||
return
|
||||
|
||||
# On MySQL, use a SEPARATE engine in AUTOCOMMIT, not db.engine. Two reasons,
|
||||
# both of which burned real debugging time:
|
||||
# 1. db.engine's pool keeps connections idle-in-transaction (Flask-
|
||||
# SQLAlchemy has no request-teardown in a CLI context); a DROP sharing
|
||||
# that pool waits on their locks.
|
||||
# 2. Without engine-level AUTOCOMMIT, SQLAlchemy runs the COUNT probes in an
|
||||
# open transaction (SET AUTOCOMMIT=0). Those reads hold shared metadata
|
||||
# locks on every victim table, so the later DROP blocks on the metadata
|
||||
# lock forever. Setting isolation_level on the CONNECTION (not the
|
||||
# engine) silently did NOT take effect - it must be on the engine.
|
||||
# With engine-level AUTOCOMMIT every statement commits on its own, so no read
|
||||
# holds a lock into the DROP phase. lock_wait_timeout makes any residual
|
||||
# contention fail fast. SQLite has none of this (no metadata locks), and a
|
||||
# separate engine to an in-memory database would be a different, empty DB, so
|
||||
# there we just use db.engine. db.session is only used above, for pluginstate.
|
||||
dialect = engine.dialect.name
|
||||
db.session.remove()
|
||||
if dialect == 'mysql':
|
||||
prune_engine = create_engine(
|
||||
current_app.config['SQLALCHEMY_DATABASE_URI'],
|
||||
isolation_level='AUTOCOMMIT')
|
||||
own_engine = True
|
||||
else:
|
||||
prune_engine = engine
|
||||
own_engine = False
|
||||
|
||||
try:
|
||||
with prune_engine.connect() as conn:
|
||||
if dialect == 'mysql':
|
||||
conn.execute(text('SET SESSION lock_wait_timeout=15'))
|
||||
|
||||
# Row counts so a non-empty table is never dropped by accident.
|
||||
nonempty = []
|
||||
for plugin, tablename in victims:
|
||||
count = conn.execute(
|
||||
text(f'SELECT COUNT(*) FROM `{tablename}`')).scalar()
|
||||
if count:
|
||||
nonempty.append((plugin, tablename, count))
|
||||
|
||||
click.echo(click.style(
|
||||
f"Plugins not installed here: "
|
||||
f"{', '.join(sorted({p for p, _ in victims}))}", fg='cyan'))
|
||||
click.echo(f"Tables to prune ({len(victims)}):")
|
||||
for plugin, tablename in victims:
|
||||
click.echo(f" {tablename:32} ({plugin})")
|
||||
|
||||
if nonempty and not force:
|
||||
click.echo("")
|
||||
click.echo(click.style(
|
||||
"REFUSING: the following tables hold rows. Re-run with "
|
||||
"--force to drop them anyway (this deletes data), or install "
|
||||
"the owning plugin instead.", fg='red'))
|
||||
for plugin, tablename, count in nonempty:
|
||||
click.echo(click.style(f" {tablename:32} {count} rows",
|
||||
fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
if not yes:
|
||||
click.echo("")
|
||||
click.echo("Dry-run. Re-run with --yes to drop the tables above.")
|
||||
return
|
||||
|
||||
# Intra-plugin foreign keys mean drop order matters; disable the
|
||||
# checks for the batch rather than topologically sorting the tables
|
||||
# without the models. Restore the PRIOR foreign-key setting after,
|
||||
# not a hard ON: on SQLite the same connection is reused (StaticPool),
|
||||
# so forcing ON would leak into whatever ran next.
|
||||
fk_prev = None
|
||||
if dialect == 'mysql':
|
||||
conn.execute(text('SET FOREIGN_KEY_CHECKS=0'))
|
||||
elif dialect == 'sqlite':
|
||||
fk_prev = conn.execute(text('PRAGMA foreign_keys')).scalar()
|
||||
conn.execute(text('PRAGMA foreign_keys=OFF'))
|
||||
for _plugin, tablename in victims:
|
||||
conn.execute(text(f'DROP TABLE IF EXISTS `{tablename}`'))
|
||||
if dialect == 'mysql':
|
||||
conn.execute(text('SET FOREIGN_KEY_CHECKS=1'))
|
||||
elif dialect == 'sqlite':
|
||||
conn.execute(text(f'PRAGMA foreign_keys={int(fk_prev or 0)}'))
|
||||
conn.commit()
|
||||
finally:
|
||||
if own_engine:
|
||||
prune_engine.dispose()
|
||||
|
||||
click.echo(click.style(f"Pruned {len(victims)} table(s). This database now "
|
||||
f"carries core + installed-plugin tables only.",
|
||||
fg='green'))
|
||||
|
||||
Reference in New Issue
Block a user