Files
shopdb-flask/shopdb/plugins/registry.py
cproudlock 9b7da9f2d2 Make a plugin toggle take effect without a manual restart
Enabling a plugin could not make its routes live. Flask refuses
register_blueprint once the app has served a request, and each gunicorn worker
holds its own app object, so registering in the worker that served the toggle
would leave the others answering 404 for the same URL.

Worse, the window before the restart was a window in which workers disagreed
about reality. Four defects, all of which answered 200 while going wrong:

- Enabling a not-yet-installed plugin ALWAYS 500'd on a running app:
  install_plugin ran the migrations, updated the registry, and only then called
  register_blueprint and hit Flask's assertion. The toggle half-applied and
  reported failure.
- Enabling one plugin could hang the entire site. @jwt_required does SELECT
  users, so the request's transaction sits open holding a shared metadata lock;
  the plugin's migration runs on its own Alembic connection and needs an
  EXCLUSIVE lock on users for a foreign key. Each waits on the other, and MySQL
  grants metadata locks in request order, so every later login queued behind the
  pending exclusive. Observed twice as a total outage from one plugin toggle.
- Each worker rewrote the WHOLE registry file from a snapshot taken at boot, so
  concurrent enables erased each other: seven enables persisted three, with
  HTTP 200 for all seven.
- Dependency checks read that same stale snapshot, refusing a plugin whose
  dependency another worker had just enabled.

Routes still require the process to restart; what changes is who initiates it.
Each worker fingerprints instance/plugins.json at boot and exits gracefully when
it changes, and the supervisor replaces it. That is the primitive gunicorn's own
--reload uses. Recycles are staggered per worker so the pool rolls rather than
dropping at once, and the exit is scoped to gunicorn: on Windows sys.exit() in a
daemon thread ends the thread rather than the process, so IIS keeps the manual
restart until that path is proven on the Windows VM.

The registry now merges under an exclusive lock and replaces atomically, saves
only the entry being changed, and re-reads before any decision is made from it.

ADR-020 records the decision; ADR-013 is amended where it said the UI must tell
the operator to restart. The restart requirement and the honesty requirement
both stand - only the manual step is removed, and only where it is proven.

(cherry picked from commit 9d6342b3ab)
2026-09-20 09:07:54 -04:00

267 lines
11 KiB
Python

"""Plugin registry for tracking installed and enabled plugins."""
import json
import logging
from pathlib import Path
from typing import Dict, List, Optional
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
@dataclass
class PluginState:
"""Persistent state for a plugin."""
name: str
version: str
installed_at: str
enabled: bool = True
migrations_applied: List[str] = field(default_factory=list)
config: Dict = field(default_factory=dict)
class PluginRegistry:
"""
Manages plugin state persistence.
Stores state in JSON file in instance folder.
"""
def __init__(self, state_file: Path):
self.state_file = state_file
self._plugins: Dict[str, PluginState] = {}
self._load()
def _load(self) -> None:
"""Replace this instance's view with what is on disk.
REPLACE, not merge. Assigning into the existing dict left entries that
the file no longer has, so a plugin uninstalled through another worker
stayed 'installed' here forever: enable_plugin would then skip
install_plugin (and its migrations) and _save would write the ghost back
out, resurrecting a plugin whose tables were never created. refresh()
exists precisely to end stale reads, and it could not while this merged.
"""
loaded = {}
if self.state_file.exists():
try:
with open(self.state_file, 'r') as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
data = None
for name, state_data in ((data or {}).get('plugins') or {}).items():
try:
loaded[name] = PluginState(**state_data)
except TypeError:
# A key written by a newer build. Skip THIS entry and keep
# the rest: discarding the whole registry made every
# subsequent enable a silent no-op, because enable() only
# acts on names it can see and still answered 200.
logger.warning(
'plugins.json entry %r has unexpected fields; ignoring '
'it in this process', name)
continue
self._plugins = loaded
self._migrate_renamed_plugins()
def _migrate_renamed_plugins(self) -> None:
"""Upgrade path for the equipment -> machines plugin rename.
Existing installs carry an 'equipment' entry in plugins.json. When the
renamed plugins/machines directory exists and no 'machines' entry does,
carry the state over under the new name and persist.
"""
if 'equipment' not in self._plugins or 'machines' in self._plugins:
return
machines_dir = Path(__file__).resolve().parents[2] / 'plugins' / 'machines'
if not machines_dir.exists():
return
state = self._plugins.pop('equipment')
state.name = 'machines'
self._plugins['machines'] = state
self._save(only={'machines'}, remove={'equipment'})
def _save(self, remove: set = None, only: set = None) -> None:
"""Persist this registry, merging rather than overwriting.
LOST UPDATES, AND WHY A PLAIN WRITE IS WRONG. Every gunicorn worker
builds its own PluginRegistry and loads plugins.json once at boot, so
each holds a snapshot that goes stale the moment another worker changes
anything. Rewriting the whole file from that snapshot therefore erases
whatever the other workers recorded: enabling seven plugins across four
workers persisted three, and the API answered 200 for all seven. Silent
data loss, with no error anywhere to hint at it.
So take an exclusive lock, re-read what is on disk, apply only the
entries this instance actually holds on top of it, and replace the file
atomically. Concurrent writers then serialise instead of clobbering, and
a crash mid-write cannot leave a half-written registry.
"""
import os
import tempfile
self.state_file.parent.mkdir(parents=True, exist_ok=True)
lock_path = self.state_file.with_suffix('.lock')
try:
import fcntl
except ImportError: # non-POSIX: fall back to the plain write
fcntl = None
lock_handle = None
try:
if fcntl is not None:
lock_handle = open(lock_path, 'w')
fcntl.flock(lock_handle, fcntl.LOCK_EX)
merged = {}
if self.state_file.exists():
try:
with open(self.state_file) as handle:
merged = (json.load(handle) or {}).get('plugins', {}) or {}
except (json.JSONDecodeError, TypeError, OSError):
merged = {}
# Overlay ONLY the entries this call is changing. Writing every
# entry this instance holds is what loses other workers' updates:
# a snapshot taken at boot still says X is disabled, so saving an
# unrelated change to Y drags X back down with it. `only` is the
# mutation actually being persisted.
for name, state in self._plugins.items():
if only is not None and name not in only:
continue
merged[name] = asdict(state)
# A delete cannot be expressed by merging - the entry is simply
# absent from self._plugins, and the on-disk copy would survive and
# read as a resurrection. Name it instead.
for name in (remove or set()):
merged.pop(name, None)
# Adopt the merged view so this instance stops carrying a stale
# snapshot that a later save would write back out.
rebuilt = {}
for name, data in merged.items():
try:
rebuilt[name] = PluginState(**data)
except TypeError:
# An unknown key from a newer build must not turn every
# enable into a 500; keep the row on disk, skip it here.
continue
self._plugins = rebuilt
handle = tempfile.NamedTemporaryFile(
'w', dir=str(self.state_file.parent), delete=False)
try:
json.dump({'plugins': merged}, handle, indent=2)
handle.flush()
os.fsync(handle.fileno())
finally:
handle.close()
os.replace(handle.name, self.state_file)
return
finally:
if lock_handle is not None:
try:
fcntl.flock(lock_handle, fcntl.LOCK_UN)
lock_handle.close()
except Exception:
pass
def _save_unmerged(self) -> None:
"""Original whole-file write. Kept for callers that own the file."""
self.state_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.state_file, 'w') as f:
json.dump({
'plugins': {
name: asdict(state)
for name, state in self._plugins.items()
}
}, f, indent=2)
def register(self, name: str, version: str, enabled: bool = True) -> PluginState:
"""Register a newly installed plugin. enabled=False leaves it off until a
site opts in (used for plugins that provision extra tables)."""
state = PluginState(
name=name,
version=version,
installed_at=datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
enabled=enabled
)
self._load() # another worker may have changed the file
self._plugins[name] = state
self._save(only={name})
return state
def unregister(self, name: str) -> None:
"""Remove plugin from registry."""
self._load() # another worker may have registered it since boot
self._plugins.pop(name, None)
self._save(remove={name})
def refresh(self) -> None:
"""Re-read plugins.json before making a decision from it.
Every worker loads this file once at boot, so any read used to DECIDE
something - is it installed, is a dependency enabled - is answering from
a snapshot that another worker may have moved on from. That is how
enabling endpoints returned 409 "computers is not enabled" moments after
computers had in fact been enabled through a different worker.
"""
self._load()
def get(self, name: str) -> Optional[PluginState]:
"""Get plugin state."""
return self._plugins.get(name)
def is_installed(self, name: str) -> bool:
"""Check if plugin is installed."""
return name in self._plugins
def is_enabled(self, name: str) -> bool:
"""Check if plugin is enabled."""
state = self._plugins.get(name)
return state.enabled if state else False
def enable(self, name: str) -> None:
"""Enable a plugin.
Re-reads first: this worker's snapshot dates from boot, so a plugin
another worker installed since is absent here and the old
`if name in self._plugins` guard silently did nothing.
"""
self._load()
if name in self._plugins:
self._plugins[name].enabled = True
self._save(only={name})
def disable(self, name: str) -> None:
"""Disable a plugin. Re-reads first, for the same reason as enable."""
self._load()
if name in self._plugins:
self._plugins[name].enabled = False
self._save(only={name})
def get_enabled_plugins(self) -> List[str]:
"""Get list of enabled plugin names."""
return [
name for name, state in self._plugins.items()
if state.enabled
]
def add_migration(self, name: str, revision: str) -> None:
"""Record that a migration was applied."""
self._load()
if name in self._plugins:
if revision not in self._plugins[name].migrations_applied:
self._plugins[name].migrations_applied.append(revision)
self._save(only={name})
def get_all(self) -> Dict[str, PluginState]:
"""Get all registered plugins."""
return self._plugins.copy()
def update_config(self, name: str, config: Dict) -> None:
"""Update plugin configuration."""
self._load()
if name in self._plugins:
self._plugins[name].config.update(config)
self._save(only={name})