KRITISCH: 'database is locked' beim Aktivieren blockierte Online- UND Offline-Aktivierung (v1.2.1)

_process_activate() hielt beim Ersetzen einer bereits aktiven Lizenz
fuer denselben Fingerprint (z.B. Trial durch Enterprise, oder schlicht
eine Re-Aktivierung nach einem Systemwechsel) eine offene, noch nicht
committete Schreibtransaktion auf 'conn', waehrend es innerhalb der
Supersede-Schleife log_action_system() aufrief -- diese Funktion oeffnet
INTERN eine EIGENE, zweite Verbindung zur selben Datenbank und versucht
sofort zu committen. SQLite erlaubt aber nur einen Schreiber gleichzeitig
(kein WAL-Modus): die zweite Verbindung wartet auf die erste, die
wiederum auf die zweite wartet, bis das Busy-Timeout (Python-Default 5s)
mit 'sqlite3.OperationalError: database is locked' aufgibt.

Live reproduziert: sowohl /api/activate (Online) als auch
/licenses/manual-code (Offline-Code) schlugen dadurch fehl, sobald ein
Kunde eine Lizenz aktivierte, die eine fuer denselben Fingerprint bereits
aktive ersetzt -- de facto war jede Aktivierung auf einem bereits
bekannten System blockiert.

Fix: die betroffenen log_action_system()-Aufrufe in der Supersede-
Schleife werden nur noch vorgemerkt (reine Lesezugriffe auf die
offene Verbindung sind unproblematisch) und erst NACH conn.commit()/
conn.close() nachgeholt. Zusaetzlich get_db_connection() auf
timeout=15 (statt Python-Default 5s) angehoben als generelle
Verteidigungslinie gegen kurze, legitime Ueberschneidungen -- ersetzt
nicht die eigentliche Regel (niemals eine zweite Verbindung waehrend
einer noch offenen Schreibtransaktion committen), ist aber zusaetzliche
Sicherheit.

Alle anderen log_action()/log_action_system()-Aufrufstellen in dieser
Session (customers(), ticket_new/-edit/-delete, license_delete,
customer_portal, _issue_license_from_ticket, _revoke_license_row,
_process_deactivate, _process_heartbeat) wurden systematisch geprueft --
committen bereits korrekt VOR dem Logging, betroffen war ausschliesslich
diese eine Stelle.

Verifiziert: direkter Reproduktionstest gegen eine echte Kopie der
Live-Datenbank (POETESTs echte aktive Lizenz wird durch eine zweite
ersetzt, exakt das gemeldete Szenario) -- vorher deadlock/Timeout,
nachher 0.04s, korrekt superseded + Hostname + Audit-Log-Eintraege.
Als Notfall-Hotfix bereits direkt auf dem Live-Master eingespielt
(Dienst neu gestartet, seither keine weiteren 'database is locked'-
Fehler); dieser Commit bringt Repo/Release auf denselben Stand, damit
'latest' nicht wieder auf die kaputte v1.2.0 zurueckfaellt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 16:33:16 +02:00
co-authored by Claude Sonnet 5
parent cee5311b1d
commit 15087d4293
2 changed files with 26 additions and 5 deletions
+1 -1
View File
@@ -1 +1 @@
1.2.0 1.2.1
+25 -4
View File
@@ -580,7 +580,14 @@ class User(UserMixin):
def get_db_connection(): def get_db_connection():
conn = sqlite3.connect(DB_PATH) # timeout=15 statt Python-Default 5s: reine Verteidigungslinie gegen
# kurze, legitime Ueberschneidungen zweier Schreibzugriffe (SQLite
# erlaubt nur EINEN Schreiber gleichzeitig) -- ersetzt NICHT die
# eigentliche Regel, niemals eine zweite Verbindung waehrend einer noch
# offenen Schreibtransaktion auf derselben zu committen (siehe
# _process_activate für ein Beispiel, wo genau das den Fehler
# "database is locked" ausgeloest hat).
conn = sqlite3.connect(DB_PATH, timeout=15)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
return conn return conn
@@ -2202,19 +2209,33 @@ def _process_activate(data):
"SELECT license_id, customer_id FROM licenses WHERE fingerprint=? AND status='active' AND license_id!=?", "SELECT license_id, customer_id FROM licenses WHERE fingerprint=? AND status='active' AND license_id!=?",
(fingerprint, license_id), (fingerprint, license_id),
).fetchall() ).fetchall()
# log_action_system() oeffnet INTERN eine EIGENE, zweite Verbindung und
# committet sofort -- solange `conn` hier noch eine offene Schreib-
# transaktion haelt (erstes UPDATE oben, noch nicht committet), wuerde
# dieser zweite Commit-Versuch auf sich selbst warten (SQLite erlaubt
# nur EINEN Schreiber gleichzeitig) und nach Ablauf des Busy-Timeouts
# mit "database is locked" fehlschlagen -- live reproduziert beim
# Aktivieren einer Lizenz, die eine andere fuer denselben Fingerprint
# ersetzt. Deshalb werden die Log-Eintraege hier nur VORGEMERKT (reine
# Lesezugriffe auf `conn` sind unproblematisch, nur der schreibende
# log_action_system()-Aufruf selbst muss warten) und dann als allerletzter
# Schritt nach `conn.commit()`/`conn.close()` unten nachgeholt.
superseded_logs = []
for old in superseded: for old in superseded:
conn.execute( conn.execute(
"UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?", "UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?",
(now, old["license_id"]), (now, old["license_id"]),
) )
old_customer = _customer_row(conn, old["customer_id"]) old_customer = _customer_row(conn, old["customer_id"])
log_action_system( superseded_logs.append((
"license.superseded", old_customer["name"] if old_customer else old["license_id"], old_customer["name"] if old_customer else old["license_id"],
f"Automatisch deaktiviert -- Fingerprint {fingerprint} hat jetzt Lizenz {license_id} aktiviert.", f"Automatisch deaktiviert -- Fingerprint {fingerprint} hat jetzt Lizenz {license_id} aktiviert.",
) ))
conn.commit() conn.commit()
customer = _customer_row(conn, row["customer_id"]) customer = _customer_row(conn, row["customer_id"])
conn.close() conn.close()
for target, details in superseded_logs:
log_action_system("license.superseded", target, details)
log_action_system("license.activated_remote", customer["name"] if customer else license_id, f"Fingerprint {fingerprint}") log_action_system("license.activated_remote", customer["name"] if customer else license_id, f"Fingerprint {fingerprint}")
return licensing.build_master_response("activated", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok"), 200 return licensing.build_master_response("activated", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok"), 200