From c2989a6e2f12a5f8349f4f2cebe95a178573354c Mon Sep 17 00:00:00 2001 From: alientim Date: Sun, 23 Aug 2026 16:33:16 +0200 Subject: [PATCH] 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 --- srv/tesm-license/VERSION | 2 +- srv/tesm-license/app.py | 29 +++++++++++++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/srv/tesm-license/VERSION b/srv/tesm-license/VERSION index 26aaba0..6085e94 100644 --- a/srv/tesm-license/VERSION +++ b/srv/tesm-license/VERSION @@ -1 +1 @@ -1.2.0 +1.2.1 diff --git a/srv/tesm-license/app.py b/srv/tesm-license/app.py index 0454f3e..3681c3c 100644 --- a/srv/tesm-license/app.py +++ b/srv/tesm-license/app.py @@ -580,7 +580,14 @@ class User(UserMixin): 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 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!=?", (fingerprint, license_id), ).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: conn.execute( "UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?", (now, old["license_id"]), ) old_customer = _customer_row(conn, old["customer_id"]) - log_action_system( - "license.superseded", old_customer["name"] if old_customer else old["license_id"], + superseded_logs.append(( + old_customer["name"] if old_customer else old["license_id"], f"Automatisch deaktiviert -- Fingerprint {fingerprint} hat jetzt Lizenz {license_id} aktiviert.", - ) + )) conn.commit() customer = _customer_row(conn, row["customer_id"]) 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}") return licensing.build_master_response("activated", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok"), 200