Files
ServerKit/backend/app/services/db_process_service.py
T
Juan DenisandClaude Opus 5 b768fef728 refactor(db): one SQL-exec dispatcher, and adopt the doors already built
Plan 75 §G5 + §G6.

§G5 — app/services/db_exec.py. engine × host dispatch (mysql/postgres
crossed with native/docker) was written three times. Only
managed_db_user_service delegated fully to DatabaseService's executors;
the other two grew private copies that had already drifted —
db_process_service's docker-postgres branch built its own psql argv and
never set ON_ERROR_STOP=1, so psql exited 0 for a statement the server
had rejected and the caller was told it worked.

db_exec owns the dispatch and nothing else; the four executors stay in
DatabaseService. The three _exec_sql seams remain (tests stub them) and
now delegate. An unrecognised engine is reported, never quietly routed to
mysql, where it would surface as a syntax error blamed on the caller.

docker_mysql_execute/docker_pg_execute gain machine_readable, which is
the single flag db_config_tuner_service was keeping a whole private copy
of the dispatcher to add. The MYSQL_PWD env block, pasted six times in
database_service, becomes _mysql_env(): it carries a real rule — the
password goes in the environment, never on the argv — and a rule in six
copies only holds in the copies someone remembered.

§G6 — adopting doors Round 1 built:

- config_store's write is now atomic (tmp + os.replace). The §F4
  consolidation deliberately kept the copies' truncating write and noted
  atomicity would land here once; this is that once. A write that dies
  mid-dump leaves a truncated file, and every load path treats a corrupt
  config as "use the default" — a config silently reset.
- uptime_service._save_history was `except Exception: pass` over that
  truncating write, so a full disk destroyed the history and reported
  nothing while the panel kept rendering a figure from a file that had
  stopped updating. It now returns and logs the failure.
- unit_is_active(): `systemctl is-active` was re-inlined in four services
  and every copy collapsed "could not ask" into "not running". The shared
  probe is tri-state; None is not False.
- slug: preview_service and saved_view_service re-implemented slugify's
  regex, and saved_view re-implemented unique_slug's collision loop too.
  unique_slug gains start= so saved_view keeps generating the handles it
  always has.

Checked rather than assumed: the plan lists file_integrity_service as
silently losing data on a full disk. It does not — it already used tmp +
os.replace and does not swallow the error. It is the pattern followed
here, not a victim; only uptime_service had the silent loss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 11:26:40 -04:00

174 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Live database process inspection and termination.
Surfaces ``SHOW FULL PROCESSLIST`` (MySQL/MariaDB) and ``pg_stat_activity``
(PostgreSQL) for the Database Explorer, plus a kill/terminate action.
A *target* mirrors the explorer's connection shapes:
{'engine': 'mysql'|'postgresql', # which SQL dialect / client to use
'container': None | 'name', # set for Docker-hosted databases
'user': ..., 'password': ..., # optional credentials
'database': ...} # optional (docker postgres only)
Every statement flows through the single :meth:`DbProcessService._exec_sql`
choke-point (which reuses the exact exec pathways the explorer already uses in
``DatabaseService``), so tests can stub one method to cover everything.
"""
from app.services import db_exec
SUPPORTED_ENGINES = ('mysql', 'postgresql')
_MYSQL_PROCESSLIST = 'SHOW FULL PROCESSLIST;'
# pid | usename | datname | state | time_s | query (query LAST so the
# separator-based parse can never be broken by user SQL; newlines/tabs are
# flattened server-side for the same reason).
_PG_PROCESSLIST = (
"SELECT pid, usename, datname, state, "
"COALESCE(floor(extract(epoch FROM now() - query_start))::bigint, 0), "
"regexp_replace(COALESCE(query, ''), E'[\\n\\r\\t]+', ' ', 'g') "
"FROM pg_stat_activity "
"WHERE pid <> pg_backend_pid() "
"ORDER BY 5 DESC;"
)
class DbProcessService:
"""List and kill live server processes for MySQL/MariaDB and PostgreSQL."""
# ------------------------------------------------------------------
# Single exec choke-point
# ------------------------------------------------------------------
@staticmethod
def _exec_sql(target, sql):
"""Run ``sql`` against the target's engine — via ``db_exec`` (§G5).
The named seam stays (tests stub it); the engine × host dispatch it used
to re-implement does not. The private docker-postgres branch here never
set ``ON_ERROR_STOP=1``, so psql reported success for statements the
server had rejected — the drift that made three copies worth one.
"""
return db_exec.exec_sql(target, sql)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@classmethod
def list_processes(cls, target):
"""List live server processes, normalized across engines.
Returns ``{'processes': [{id, user, db, state, command, time_s,
query}, ...]}`` or ``{'error': msg}``.
"""
engine = (target or {}).get('engine')
if engine not in SUPPORTED_ENGINES:
return {'error': 'unsupported engine'}
sql = _MYSQL_PROCESSLIST if engine == 'mysql' else _PG_PROCESSLIST
result = cls._exec_sql(target, sql)
if not result.get('success'):
return {'error': result.get('error') or 'failed to list processes'}
output = result.get('output') or ''
if engine == 'mysql':
return {'processes': cls._parse_mysql_processlist(output)}
return {'processes': cls._parse_pg_activity(output)}
@classmethod
def kill_process(cls, target, pid):
"""Kill/terminate a server process by id.
MySQL: ``KILL <id>``; PostgreSQL: ``SELECT pg_terminate_backend(<pid>)``.
``pid`` is validated as an integer before it goes anywhere near SQL.
"""
engine = (target or {}).get('engine')
if engine not in SUPPORTED_ENGINES:
return {'error': 'unsupported engine'}
try:
pid = int(pid)
except (TypeError, ValueError):
return {'error': 'pid must be an integer'}
if engine == 'mysql':
sql = f'KILL {pid};'
else:
sql = f'SELECT pg_terminate_backend({pid});'
result = cls._exec_sql(target, sql)
if not result.get('success'):
return {'error': result.get('error') or 'failed to kill process'}
if engine == 'postgresql' and (result.get('output') or '').strip() == 'f':
return {'error': 'process not found or could not be terminated'}
return {'success': True, 'pid': pid}
# ------------------------------------------------------------------
# Output parsing / normalization
# ------------------------------------------------------------------
@staticmethod
def _parse_mysql_processlist(output):
"""Parse tab-separated ``SHOW FULL PROCESSLIST`` batch output.
Columns: Id, User, Host, db, Command, Time, State, Info. ``Info``
(the query) is last, so a bounded split keeps embedded tabs safe —
the mysql client already escapes newlines in batch mode.
"""
processes = []
lines = [ln for ln in output.split('\n') if ln.strip()]
for line in lines:
parts = line.split('\t', 7)
if len(parts) < 7:
continue
try:
proc_id = int(parts[0])
except ValueError:
continue # header row ("Id\tUser\t...") or noise
info = parts[7] if len(parts) > 7 else ''
try:
time_s = int(parts[5])
except ValueError:
time_s = 0
processes.append({
'id': proc_id,
'user': parts[1],
'db': None if parts[3] == 'NULL' else parts[3],
'command': parts[4],
'time_s': time_s,
'state': parts[6],
'query': '' if info in ('NULL', '') else info,
})
return processes
@staticmethod
def _parse_pg_activity(output):
"""Parse ``psql -t -A`` ('|'-separated, no header) activity rows.
The query column is selected last and split with a bound, so query
text containing '|' cannot shift fields.
"""
processes = []
for line in output.split('\n'):
line = line.strip()
if not line:
continue
parts = line.split('|', 5)
if len(parts) < 6:
continue
try:
pid = int(parts[0])
except ValueError:
continue
try:
time_s = int(float(parts[4])) if parts[4] else 0
except ValueError:
time_s = 0
processes.append({
'id': pid,
'user': parts[1] or None,
'db': parts[2] or None,
'command': '',
'time_s': time_s,
'state': parts[3] or 'idle',
'query': parts[5],
})
return processes