Jul 31 - Fix database issue which leads to the app dies

This commit is contained in:
2026-07-31 10:07:07 -04:00
parent 24d48f5d34
commit a7655810d8
3 changed files with 72 additions and 8 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(grep -vE \"^[+-]\\\\s*$\")",
"Bash(python -c \"import ast;ast.parse\\(open\\('app.py'\\).read\\(\\)\\);ast.parse\\(open\\('config.py'\\).read\\(\\)\\);print\\('AST OK'\\)\")",
"Bash(python -c ' *)"
]
}
}
+46 -4
View File
@@ -314,16 +314,58 @@ def create_app() -> Flask:
# ------------------------------------------------------------------
# Startup initialization (runs under gunicorn and flask run alike)
# ------------------------------------------------------------------
_run_startup_initialization(app)
return app
def _run_startup_initialization(app) -> bool:
"""Run create_tables() + update_existing_qr_codes(), tolerating a DB that
is not up yet.
After a server reboot gunicorn and mysqld start in parallel, so the first
connection attempt can be refused. Retry for a short bounded window, then
boot the app anyway instead of re-raising: a worker that refuses to start
takes the whole site down permanently (supervisor exhausts its start
retries within seconds and gives up), while a booted worker recovers on its
own once MySQL accepts connections — pool_pre_ping discards the dead
connections. Startup work that was skipped is idempotent and runs on the
next successful restart.
"""
from extensions import logger_handler as _startup_lh
attempts = app.config.get('DB_STARTUP_RETRY_ATTEMPTS', 5)
delay = app.config.get('DB_STARTUP_RETRY_DELAY', 3)
last_error = None
for attempt in range(1, attempts + 1):
with app.app_context():
try:
create_tables()
update_existing_qr_codes()
if attempt > 1:
_startup_lh.logger.info(
f"Startup initialization succeeded on attempt {attempt}/{attempts}"
)
return True
except Exception as e:
from extensions import logger_handler as _startup_lh
_startup_lh.logger.error(f"Startup initialization failed: {e}", exc_info=True)
raise
last_error = e
try:
db.session.rollback()
except Exception:
pass
_startup_lh.logger.warning(
f"Startup initialization attempt {attempt}/{attempts} failed: {e}"
)
if attempt < attempts:
_time.sleep(delay)
return app
_startup_lh.logger.error(
f"Startup initialization failed after {attempts} attempts; starting anyway "
f"so workers can serve once the database recovers: {last_error}",
exc_info=True
)
return False
# ---------------------------------------------------------------------------
+13
View File
@@ -41,8 +41,21 @@ class Config:
'pool_timeout': int(os.environ.get('SQLALCHEMY_ENGINE_OPTIONS_POOL_TIMEOUT', '20')),
'pool_recycle': int(os.environ.get('SQLALCHEMY_ENGINE_OPTIONS_POOL_RECYCLE', '3600')),
'max_overflow': int(os.environ.get('SQLALCHEMY_ENGINE_OPTIONS_MAX_OVERFLOW', '20')),
# Validate a pooled connection before handing it out. Without this, every
# connection opened while mysqld was down/restarting stays in the pool as a
# dead socket and keeps failing requests long after MySQL has recovered.
'pool_pre_ping': True,
}
# ------------------------------------------------------------------ #
# Startup DB-connect retry (server reboot: gunicorn may start before mysqld)
# ------------------------------------------------------------------ #
# Observed worst case: an unattended-upgrades restart of mysql-server left
# mysqld down for ~15s. 8 attempts x 3s covers ~21s of downtime while
# staying inside gunicorn's 30s worker-boot timeout.
DB_STARTUP_RETRY_ATTEMPTS = int(os.environ.get('DB_STARTUP_RETRY_ATTEMPTS', '8'))
DB_STARTUP_RETRY_DELAY = int(os.environ.get('DB_STARTUP_RETRY_DELAY', '3'))
# ------------------------------------------------------------------ #
# Session / cookies
# ------------------------------------------------------------------ #