From 426f66a73d045d7b246525cd45ab8490ff509836 Mon Sep 17 00:00:00 2001 From: Fayupable <90789180+Fayupable@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:00:50 +0300 Subject: [PATCH] feat: add MySQL/MariaDB engine support with full session and lock tracking Adds a complete second infrastructure adapter (internal/infrastructure/mysql) implementing every application-layer port the Postgres adapter does, so pgscope can now monitor either engine via PGSCOPE_DB_ENGINE. Backend: - 20 MySQL collectors covering database size, connection saturation, duplicate/unused indexes, idle-in-transaction, top queries, long-running queries, sequence (AUTO_INCREMENT) overflow, pagination warnings, index candidates, prepared transactions, and unlogged (MEMORY) tables - Full live session/lock graph support (ISessionCollectorPort): active session listing, blocking-pid relationships, and per-lock detail with a MySQL-specific RECORD vs TABLE lock severity mapping - New MySQL-only insight: lock wait detection via sys.innodb_lock_waits, with query text always sourced from normalized DIGEST_TEXT rather than raw PROCESSLIST_INFO to avoid leaking literal query values - New GET /api/v1/connection endpoint reporting which engine is active, shaped to extend cleanly to a future multi-connection list - main.go now wires MySQL through the same Poller/SSE/history/insights flow Postgres uses, no more feature gap between the two engines Frontend: - Insights responses are normalized so MySQL's unpopulated fields (which serialize as null) never reach a component expecting an array, fixing a real crash on the Health tab - New EngineProvider/useEngine context so the UI knows which engine it's talking to - Postgres-only cards, tabs, and notices (vacuum health, checkpoints, replication lag/slots, physical I/O, function/trigger cost tracking, pg_stat_statements.track hints) are now hidden when connected to MySQL instead of showing incorrect or misleading instructions - New LockWaitCard for the MySQL-only lock wait insight - Long health-card warning lists now scroll within a max height instead of distorting the grid - Every health-card now shows a short, always-visible plain-language subtitle explaining what it checks, not just when it has a warning Every collector and adapter was verified against a live MySQL 8 container, not just reviewed for correctness. --- README.md | 81 +++++- go/cmd/pgscope/main.go | 60 +++++ go/go.mod | 2 + go/go.sum | 4 + go/internal/domain/duplicate_index.go | 20 ++ go/internal/domain/insights.go | 2 + go/internal/domain/lock_wait.go | 64 +++++ go/internal/domain/lock_wait_test.go | 97 ++++++++ go/internal/domain/unlogged_table.go | 15 ++ go/internal/domain/unused_index.go | 17 ++ go/internal/infrastructure/config/config.go | 36 +++ .../mysql/auto_increment_collector.go | 116 +++++++++ .../mysql/column_pattern_matcher.go | 79 ++++++ .../mysql/connection_saturation_collector.go | 52 ++++ .../mysql/database_size_collector.go | 75 ++++++ .../mysql/database_stats_collector.go | 147 +++++++++++ .../mysql/duplicate_index_collector.go | 75 ++++++ .../mysql/idle_in_transaction_collector.go | 59 +++++ .../mysql/index_candidate_builder.go | 65 +++++ .../mysql/index_candidate_collector.go | 125 ++++++++++ .../mysql/insights_collector.go | 143 +++++++++++ .../infrastructure/mysql/lock_severity.go | 47 ++++ .../mysql/lock_wait_collector.go | 65 +++++ .../mysql/long_running_query_collector.go | 65 +++++ .../mysql/pagination_collector.go | 70 ++++++ go/internal/infrastructure/mysql/pool.go | 56 +++++ .../mysql/prepared_transaction_collector.go | 61 +++++ .../mysql/query_text_collector.go | 52 ++++ .../infrastructure/mysql/session_collector.go | 232 ++++++++++++++++++ .../mysql/top_query_collector.go | 62 +++++ .../mysql/unlogged_table_collector.go | 51 ++++ .../mysql/unused_index_collector.go | 49 ++++ .../presentation/http/connection_handlers.go | 20 ++ go/internal/presentation/http/router.go | 29 ++- web/src/App.tsx | 9 +- .../components/CheckpointHealthCard.tsx | 12 +- .../components/ConnectionSaturationCard.tsx | 12 +- .../insights/components/DatabaseSizeCard.tsx | 6 +- .../insights/components/HealthCardHeader.tsx | 21 ++ .../insights/components/HealthPanel.tsx | 44 +++- .../insights/components/InsightsPanel.css | 26 +- .../insights/components/InsightsPanel.tsx | 17 +- .../components/InvalidObjectsCard.tsx | 12 +- .../insights/components/LockWaitCard.tsx | 31 +++ .../components/LongRunningQueriesCard.tsx | 12 +- .../components/PaginationWarningsTable.tsx | 8 +- .../components/PreparedTransactionsCard.tsx | 12 +- .../components/ReplicationSlotsCard.tsx | 12 +- .../components/SequenceOverflowTable.tsx | 13 +- .../components/UnloggedTablesCard.tsx | 12 +- web/src/shared/api/EngineProvider.tsx | 27 ++ web/src/shared/api/connectionClient.ts | 9 + web/src/shared/api/engineContext.ts | 11 + web/src/shared/api/insightsClient.ts | 51 +++- web/src/shared/types/connection.ts | 6 + web/src/shared/types/insights.ts | 13 + 56 files changed, 2478 insertions(+), 91 deletions(-) create mode 100644 go/internal/domain/lock_wait.go create mode 100644 go/internal/domain/lock_wait_test.go create mode 100644 go/internal/infrastructure/mysql/auto_increment_collector.go create mode 100644 go/internal/infrastructure/mysql/column_pattern_matcher.go create mode 100644 go/internal/infrastructure/mysql/connection_saturation_collector.go create mode 100644 go/internal/infrastructure/mysql/database_size_collector.go create mode 100644 go/internal/infrastructure/mysql/database_stats_collector.go create mode 100644 go/internal/infrastructure/mysql/duplicate_index_collector.go create mode 100644 go/internal/infrastructure/mysql/idle_in_transaction_collector.go create mode 100644 go/internal/infrastructure/mysql/index_candidate_builder.go create mode 100644 go/internal/infrastructure/mysql/index_candidate_collector.go create mode 100644 go/internal/infrastructure/mysql/insights_collector.go create mode 100644 go/internal/infrastructure/mysql/lock_severity.go create mode 100644 go/internal/infrastructure/mysql/lock_wait_collector.go create mode 100644 go/internal/infrastructure/mysql/long_running_query_collector.go create mode 100644 go/internal/infrastructure/mysql/pagination_collector.go create mode 100644 go/internal/infrastructure/mysql/pool.go create mode 100644 go/internal/infrastructure/mysql/prepared_transaction_collector.go create mode 100644 go/internal/infrastructure/mysql/query_text_collector.go create mode 100644 go/internal/infrastructure/mysql/session_collector.go create mode 100644 go/internal/infrastructure/mysql/top_query_collector.go create mode 100644 go/internal/infrastructure/mysql/unlogged_table_collector.go create mode 100644 go/internal/infrastructure/mysql/unused_index_collector.go create mode 100644 go/internal/presentation/http/connection_handlers.go create mode 100644 web/src/features/insights/components/HealthCardHeader.tsx create mode 100644 web/src/features/insights/components/LockWaitCard.tsx create mode 100644 web/src/shared/api/EngineProvider.tsx create mode 100644 web/src/shared/api/connectionClient.ts create mode 100644 web/src/shared/api/engineContext.ts create mode 100644 web/src/shared/types/connection.ts diff --git a/README.md b/README.md index a044888..a1d1a28 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pgscope -pgscope is an open-source, real-time PostgreSQL monitoring tool. It lets you see, live, what queries are running on a database, what locks each one holds, and who is waiting on whom, both as a dense table and as a visual blocking graph. Beyond live monitoring, it also generates advisory insights (slow queries, missing/duplicate/unused indexes, expensive functions and triggers, deep-offset pagination, vacuum and checkpoint health, replication lag, and more) by reading Postgres's own statistics views. +pgscope is an open-source, real-time database monitoring tool for PostgreSQL and MySQL/MariaDB. It lets you see, live, what queries are running on a database, what locks each one holds, and who is waiting on whom, both as a dense table and as a visual blocking graph. Beyond live monitoring, it also generates advisory insights (slow queries, missing/duplicate/unused indexes, deep-offset pagination, lock contention, and more) by reading each engine's own statistics views. ## Why this project, and how it differs from existing tools @@ -12,22 +12,24 @@ pgscope's actual goal is to take the kind of information a database administrato ## What pgscope is, and is not -pgscope is deliberately **read-only**. Every piece of information it shows comes from a `SELECT` query against Postgres's own statistics views (`pg_stat_activity`, `pg_stat_statements`, `pg_stat_user_tables`, `pg_locks`, and similar), the same views a database administrator would already query manually from a terminal or a database console. pgscope does not add any capability that was not already available to someone with direct database access; it only makes that same information reachable through a web interface, without requiring the terminal or console access itself. +pgscope is deliberately **read-only**. Every piece of information it shows comes from a `SELECT` query against the connected engine's own statistics views (Postgres's `pg_stat_activity`, `pg_stat_statements`, `pg_stat_user_tables`, `pg_locks`; MySQL's `performance_schema`, `sys`, `information_schema`, and similar), the same views a database administrator would already query manually from a terminal or a database console. pgscope does not add any capability that was not already available to someone with direct database access; it only makes that same information reachable through a web interface, without requiring the terminal or console access itself. -pgscope never writes, alters, or deletes anything in the database it monitors, and it never runs `EXPLAIN` against a live query either, since even a read-only `EXPLAIN` can force Postgres to actually execute parts of a statement depending on the plan. There are no "kill query" or "terminate connection" buttons, no "apply this suggested index" button, and no way to change a Postgres setting from the UI, and there never will be by default. This is an observation and advisory tool, not a control panel. +pgscope never writes, alters, or deletes anything in the database it monitors, and on Postgres it never runs `EXPLAIN` against a live query either, since even a read-only `EXPLAIN` can force Postgres to actually execute parts of a statement depending on the plan. There are no "kill query" or "terminate connection" buttons, no "apply this suggested index" button, and no way to change a database setting from the UI, and there never will be by default. This is an observation and advisory tool, not a control panel. This is a deliberate scope decision, not a missing feature. Any action beyond reading statistics (killing a session, dropping an index, changing a GUC, running an administrative function) requires a level of judgment about the current state of a live production system that pgscope, or any automated tool, cannot safely have. A wrong decision made automatically at the wrong moment can take a database down. Rather than trying to build that judgment into the tool and risk getting it wrong, pgscope leaves every action step to the people who already have direct database access and already know how to take it safely: the actual database administrators, running the actual command themselves, in their own console, with full context of what else is happening on that system at that moment. pgscope's job stops at showing them, or a less experienced teammate, that something needs attention in the first place. -Keeping the tool to `SELECT`-only queries against statistics views also keeps its security surface small and easy to reason about on purpose. A tool that only ever reads has a fundamentally smaller attack surface than one that can also write, and that difference is enforced here at the database role level, not just in the application code, so it holds even if the application itself had a bug. This guarantee is enforced at multiple independent layers (database role privileges, session settings, and the application driver itself), and each layer is documented in [go/README.md](go/README.md)'s security model section, redundant with the others on purpose: if one layer were ever misconfigured, the others still hold. +Keeping the tool to `SELECT`-only queries against statistics views also keeps its security surface small and easy to reason about on purpose. A tool that only ever reads has a fundamentally smaller attack surface than one that can also write, and that difference is enforced here at the database role level, not just in the application code, so it holds even if the application itself had a bug. On Postgres this guarantee is enforced at multiple independent layers (database role privileges, session settings, and the application driver itself); on MySQL it currently rests on database role privileges alone, since MySQL has no per-user session-settings layer equivalent to Postgres's `default_transaction_read_only` (see the MySQL setup section below). Each layer is documented in [go/README.md](go/README.md)'s security model section. ## What it targets today, and where it is going -- **Backend**: a small, self-contained Go service that connects to Postgres with a least-privilege, read-only role and streams live activity over Server-Sent Events. -- **Frontend**: a lightweight React + Vite dashboard that consumes that live stream: a session table, a blocking graph, live commit/rollback stats, a record/replay mode for going back and watching a past incident unfold second by second, and an Insights panel for advisory suggestions. +- **Backend**: a small, self-contained Go service that connects to Postgres or MySQL/MariaDB with a least-privilege, read-only role and streams live activity over Server-Sent Events. +- **Frontend**: a lightweight React + Vite dashboard that consumes that live stream: a session table, a blocking graph, live commit/rollback stats, a record/replay mode for going back and watching a past incident unfold second by second, and an Insights panel for advisory suggestions. The dashboard knows which engine it is connected to and hides suggestions that engine cannot produce, instead of showing a Postgres-only instruction against a MySQL database. -Postgres is the first engine pgscope supports, but not the only one it is meant to support. The backend is deliberately built with hexagonal architecture so that everything Postgres-specific (SQL, system view names, `pgx` driver calls) lives in one isolated package (`internal/infrastructure/postgres`) behind a set of interfaces (`internal/application/port/output`). The domain layer and the application layer that use those interfaces have no idea Postgres exists. Adding support for another database means writing a new `infrastructure/` package that implements the same interfaces; nothing in `domain`, `application`, or the frontend has to change. +Postgres was the first engine pgscope supported, and MySQL/MariaDB is the second. The backend is built with hexagonal architecture so that everything engine-specific (SQL, system view names, driver calls) lives in its own isolated package (`internal/infrastructure/postgres`, `internal/infrastructure/mysql`) behind a shared set of interfaces (`internal/application/port/output`). The domain layer and the application layer that use those interfaces have no idea which engine, or how many, are actually connected. Adding support for another database means writing a new `infrastructure/` package that implements the same interfaces; nothing in `domain`, `application`, or the frontend has to change. The MySQL adapter is the proof that this actually works rather than just a claim about the architecture: it was written without touching a single line in `domain` or `application`. -The relational engines planned after Postgres are MySQL, Oracle, and MSSQL, since all three expose comparable session, lock, and statistics views that a similar read-only role and similar `SELECT`-based queries can reach. MongoDB is also a longer-term goal, but since it is not relational, its monitoring surface (`db.currentOp()`, profiler data, `serverStatus()`) works differently enough that it needs its own research pass before an adapter can be designed properly rather than assuming Postgres's model maps over directly. The same applies to cache-oriented stores like Redis: their relevant statistics (memory usage, eviction rate, slow log) come from a different set of commands entirely, and supporting them well means reading their documentation first to understand what is actually worth surfacing, not just porting the Postgres approach over. +MySQL support is not a full duplicate of every Postgres insight, and it should not be treated as one. Some things Postgres exposes have no real MySQL equivalent and are simply not shown when connected to a MySQL database, rather than faked or approximated: vacuum health, checkpoint health, replication lag and replication slot health, physical I/O hotspots (which depend on the Postgres-only `pg_stat_kcache` extension), invalid indexes and unvalidated constraints (Postgres-specific catalog states left behind by an aborted `CREATE INDEX CONCURRENTLY` or a `NOT VALID` constraint), and function/trigger cost tracking (MySQL's `performance_schema` does not track a stored routine's own execution time separately from the statement that called it, and does not track trigger execution at all). Function and trigger cost tracking specifically is not a paid-tier limitation, it is a genuine gap in what MySQL's own instrumentation exposes, at least in the community edition. MySQL does have one insight Postgres does not: row-lock wait detection via `sys.innodb_lock_waits`, showing which session is blocked on which and for how long, since Postgres's own equivalent (`pg_locks` plus `pg_blocking_pids()`) is already covered by pgscope's live blocking graph rather than being a separate advisory. + +The relational engines planned after MySQL are Oracle and MSSQL, since both expose comparable session, lock, and statistics views that a similar read-only role and similar `SELECT`-based queries can reach. MongoDB is also a longer-term goal, but since it is not relational, its monitoring surface (`db.currentOp()`, profiler data, `serverStatus()`) works differently enough that it needs its own research pass before an adapter can be designed properly rather than assuming the relational model maps over directly. The same applies to cache-oriented stores like Redis: their relevant statistics (memory usage, eviction rate, slow log) come from a different set of commands entirely, and supporting them well means reading their documentation first to understand what is actually worth surfacing, not just porting the relational approach over. ## Access control @@ -128,6 +130,69 @@ Connect as `pgscope_agent` with a **fresh** connection: If both of those behave as expected, the database side is done. Backend and frontend setup are documented separately, since those are about running the actual application, not preparing Postgres. +## MySQL/MariaDB setup + +pgscope needs a MySQL 8.0+ (or compatible MariaDB) instance reachable over TCP. This is the full setup, typically run against a `mysql:8.0` container via Docker Compose. + +### 1. Server-level config + +Add this to your `docker-compose.yaml` mysql service: + + command: + - "--performance-schema=ON" + +`performance_schema` ships on by default on most MySQL 8.0 builds, but pass it explicitly rather than assuming, since it is what pgscope's session/lock collectors and normalized query text both depend on. Unlike Postgres's `pg_stat_statements`, this needs no extension install and no per-database enable step, it is a server-wide setting only. + +Verify it took effect: + + docker exec mysql -uroot -p -e "SHOW VARIABLES LIKE 'performance_schema';" + +### 2. Create a dedicated database and a monitoring user + +The standard `mysql:8.0` Docker image's `MYSQL_USER`/`MYSQL_PASSWORD`/`MYSQL_DATABASE` environment variables create that user with **every privilege** on that database by default, which is the opposite of what pgscope needs. Either create the user manually instead of through those variables, or create it that way and then revoke everything, as step 4 below does. + + CREATE DATABASE pgscope_demo; + +Generate a strong password first: + + openssl rand -base64 48 + + CREATE USER 'pgscope_agent'@'%' IDENTIFIED BY ''; + +### 3. Grant read access to the statistics views + + GRANT SELECT ON performance_schema.* TO 'pgscope_agent'@'%'; + GRANT SELECT ON sys.* TO 'pgscope_agent'@'%'; + GRANT EXECUTE ON sys.* TO 'pgscope_agent'@'%'; + GRANT PROCESS ON *.* TO 'pgscope_agent'@'%'; + +**Easy to miss:** the `sys` schema's own views (`sys.innodb_lock_waits`, `sys.schema_unused_indexes`, `sys.schema_redundant_indexes`) are `SQL SECURITY INVOKER` views that call stored functions defined in `sys` itself (`sys.format_statement`, `sys.quote_identifier`, and similar). `GRANT SELECT ON sys.*` alone is not enough to use them; without `GRANT EXECUTE ON sys.*` too, every query against those views fails with `Error 1356: View ... references invalid table(s) or column(s) or function(s) or definer/invoker of view lack rights to use them`, a confusing error that does not obviously point at a missing `EXECUTE` grant. `PROCESS` is what makes every session visible in `performance_schema.threads`, not just the monitoring connection's own, MySQL's rough equivalent of Postgres's `pg_monitor` role. + +### 4. Lock the user down + +If the user was created through the Docker image's `MYSQL_USER` variables (see step 2), revoke the default grant first: + + REVOKE ALL PRIVILEGES ON pgscope_demo.* FROM 'pgscope_agent'@'%'; + +Either way, confirm the user has no table-level access to your actual application data, only to the statistics schemas granted in step 3: + + SHOW GRANTS FOR 'pgscope_agent'@'%'; + +This is what makes the read-only claim actually true rather than just a UI restriction: `pgscope_agent` should show grants on `performance_schema`, `sys`, and `PROCESS` only, nothing on the application database's own tables. + +### 5. Timeouts + +Postgres lets a role carry its own default `statement_timeout` and `lock_timeout` (`ALTER ROLE ... SET ...`), so pgscope's Postgres role enforces short timeouts on itself automatically, on every connection, with no cooperation needed from the application. MySQL has no equivalent per-user default: `max_execution_time` is either a server-wide global or something a client sets for its own session after connecting, not something a role carries with it. pgscope does not currently set it per-session on the MySQL connections it opens (see `internal/infrastructure/mysql/pool.go`), so this layer of defense that exists on the Postgres side is not yet duplicated on the MySQL side. Read-only enforcement (step 3 and 4 above, grant-level) still holds regardless; this is specifically about a runaway monitoring query being killed automatically, which today only happens on the Postgres adapter. + +### 6. Verify + +Connect as `pgscope_agent` with a **fresh** connection: + + SELECT id, `user`, db, command FROM information_schema.processlist; -- should show all sessions + CREATE TABLE test_write (id int); -- must fail: no privilege on the application schema + +If both of those behave as expected, the database side is done. Point `PGSCOPE_DATABASE_URL` at this user and set `PGSCOPE_DB_ENGINE=mysql` (it defaults to `postgres` otherwise); backend and frontend setup are documented separately, since those are about running the actual application, not preparing the database. + ## Project layout go/ — backend (Go) diff --git a/go/cmd/pgscope/main.go b/go/cmd/pgscope/main.go index 40acbd0..77108d5 100644 --- a/go/cmd/pgscope/main.go +++ b/go/cmd/pgscope/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "errors" "fmt" "log/slog" @@ -15,6 +16,7 @@ import ( "github.com/fayupable/pgscope/internal/application/service" "github.com/fayupable/pgscope/internal/infrastructure/config" "github.com/fayupable/pgscope/internal/infrastructure/history" + "github.com/fayupable/pgscope/internal/infrastructure/mysql" "github.com/fayupable/pgscope/internal/infrastructure/postgres" "github.com/fayupable/pgscope/internal/infrastructure/sse" presentationhttp "github.com/fayupable/pgscope/internal/presentation/http" @@ -38,6 +40,12 @@ func run() error { return err } + // The engine selection point: each engine gets its own infrastructure + // adapter package implementing the same application-layer ports. + if cfg.Engine == config.DBEngineMySQL { + return runMySQL(ctx, cfg) + } + pool, err := postgres.NewPool(ctx, postgres.PoolConfig{ConnString: cfg.DatabaseURL}) if err != nil { return err @@ -84,6 +92,28 @@ func buildInsightsService(pool *pgxpool.Pool) *service.InsightsService { return service.NewInsightsService(insightsCollector) } +func buildMySQLPoller(pool *sql.DB, broadcaster *sse.Broadcaster, historyStore *history.SQLiteStore, cfg config.Config) *service.Poller { + collector := mysql.NewSessionCollector(pool) + monitoringService := service.NewMonitoringService(collector) + + dbStatsCollector := mysql.NewDatabaseStatsCollector(pool) + publisher := sse.NewSessionPublisher(broadcaster) + + return service.NewPoller( + monitoringService, + dbStatsCollector, + publisher, + historyStore, + cfg.PollInterval, + cfg.HistoryRecordInterval, + cfg.HistoryMaxSessionsPerSnapshot, + ) +} + +func buildMySQLInsightsService(pool *sql.DB) *service.InsightsService { + return service.NewInsightsService(mysql.NewInsightsCollector(pool)) +} + func buildServer(broadcaster *sse.Broadcaster, poller *service.Poller, insightsService *service.InsightsService, cfg config.Config) *http.Server { mux := presentationhttp.NewRouter(broadcaster, poller, insightsService, cfg) @@ -97,6 +127,36 @@ func buildServer(broadcaster *sse.Broadcaster, poller *service.Poller, insightsS } } +// runMySQL mirrors run()'s Postgres flow exactly, using mysql.* adapters — +// full live SSE session/lock stream, monitor start/stop, history, and +// insights, all backed by mysql.SessionCollector/DatabaseStatsCollector/ +// InsightsCollector now that MySQL implements every port the Postgres +// engine does. +func runMySQL(ctx context.Context, cfg config.Config) error { + pool, err := mysql.NewPool(ctx, mysql.PoolConfig{DSN: cfg.DatabaseURL}) + if err != nil { + return err + } + defer func() { _ = pool.Close() }() + + historyStore, err := history.NewSQLiteStore(cfg.HistoryDBPath) + if err != nil { + return fmt.Errorf("open history store: %w", err) + } + defer func() { _ = historyStore.Close() }() + + broadcaster := sse.NewBroadcaster() + poller := buildMySQLPoller(pool, broadcaster, historyStore, cfg) + insightsService := buildMySQLInsightsService(pool) + server := buildServer(broadcaster, poller, insightsService, cfg) + + go poller.Run(ctx) + go runHistoryPruner(ctx, historyStore, cfg.HistoryRetention, cfg.HistoryMaxDBSizeBytes) + + slog.Info("running MySQL engine", "port", cfg.HTTPPort) + return runServer(ctx, server) +} + func runServer(ctx context.Context, server *http.Server) error { errChan := make(chan error, 1) go func() { diff --git a/go/go.mod b/go/go.mod index e681b1e..6f99d0b 100644 --- a/go/go.mod +++ b/go/go.mod @@ -9,7 +9,9 @@ require ( ) require ( + filippo.io/edwards25519 v1.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/go/go.sum b/go/go.sum index b0e2a77..befdf95 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,8 +1,12 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/go/internal/domain/duplicate_index.go b/go/internal/domain/duplicate_index.go index ef916e6..5968d4a 100644 --- a/go/internal/domain/duplicate_index.go +++ b/go/internal/domain/duplicate_index.go @@ -134,6 +134,26 @@ func preferOther(candidate, other IndexInfo) bool { return candidate.Name > other.Name } +// NewDuplicateIndex builds a DuplicateIndex from a redundancy determination +// already made by the source engine itself (e.g. MySQL's +// sys.schema_redundant_indexes, which computes this the same way +// DetectDuplicateIndexes does for Postgres, just server-side) — the +// explanation wording stays in the domain layer either way, so every +// engine's duplicate-index message reads consistently. +func NewDuplicateIndex(table, redundantIndex, coveringIndex string, redundantColumns, coveringColumns []string) DuplicateIndex { + return DuplicateIndex{ + Table: table, + RedundantIndex: redundantIndex, + CoveringIndex: coveringIndex, + RedundantColumns: redundantColumns, + CoveringColumns: coveringColumns, + Explanation: fmt.Sprintf( + "Index %q on (%s) appears redundant — index %q on (%s) already covers the same lookups. Verify against your actual query patterns before dropping it.", + redundantIndex, strings.Join(redundantColumns, ", "), coveringIndex, strings.Join(coveringColumns, ", "), + ), + } +} + func buildDuplicateExplanation(candidate, covering IndexInfo) string { var b strings.Builder _, _ = fmt.Fprintf(&b, "Index %q on (%s) appears redundant — index %q on (%s) already covers the same lookups", diff --git a/go/internal/domain/insights.go b/go/internal/domain/insights.go index 65fab53..f687abd 100644 --- a/go/internal/domain/insights.go +++ b/go/internal/domain/insights.go @@ -54,4 +54,6 @@ type Insights struct { ReplicationSlotWarnings []ReplicationSlotWarning `json:"replicationSlotWarnings"` LongRunningQueryWarnings []LongRunningQueryWarning `json:"longRunningQueryWarnings"` UnloggedTables []UnloggedTable `json:"unloggedTables"` + // LockWaitWarnings is MySQL-only for now — see domain.LockWaitSession. + LockWaitWarnings []LockWaitWarning `json:"lockWaitWarnings"` } diff --git a/go/internal/domain/lock_wait.go b/go/internal/domain/lock_wait.go new file mode 100644 index 0000000..d349d14 --- /dev/null +++ b/go/internal/domain/lock_wait.go @@ -0,0 +1,64 @@ +package domain + +import "fmt" + +const LockWaitWarningSeconds = 5.0 + +// LockWaitSession is one session currently blocked waiting to acquire a +// row lock another session already holds — no judgment applied yet. This +// has no direct Postgres equivalent in pgscope today: Postgres exposes +// lock waits through pg_locks joined against pg_stat_activity, which +// pgscope doesn't yet surface as its own insight (that's part of the +// still-unbuilt live session/lock graph). MySQL's sys.innodb_lock_waits +// view makes the same relationship — who is blocking whom — available as +// a simple read, so this ships as a MySQL-only insight for now. +type LockWaitSession struct { + WaitingPID int32 + WaitingQuery string + BlockingPID int32 + BlockingQuery string + LockedTable string + WaitAgeSeconds float64 +} + +// LockWaitWarning is a suggestion, never a certainty — brief lock waits +// (a few milliseconds to a couple seconds) are completely normal in any +// database under concurrent write load. One stretching well beyond that +// usually means a transaction is holding a lock much longer than it +// needs to (an idle-in-transaction session, a slow client, a forgotten +// COMMIT), and every other session waiting behind it is now stalled too. +type LockWaitWarning struct { + WaitingPID int32 `json:"waitingPid"` + WaitingQuery string `json:"waitingQuery"` + BlockingPID int32 `json:"blockingPid"` + BlockingQuery string `json:"blockingQuery"` + LockedTable string `json:"lockedTable"` + WaitAgeSeconds float64 `json:"waitAgeSeconds"` + Explanation string `json:"explanation"` +} + +// DetectLockWaitWarnings filters lock waits down to the ones stretching +// long enough to matter — most lock waits resolve in well under a +// second and are just normal contention, not a signal. +func DetectLockWaitWarnings(sessions []LockWaitSession) []LockWaitWarning { + result := make([]LockWaitWarning, 0) + for _, s := range sessions { + if s.WaitAgeSeconds < LockWaitWarningSeconds { + continue + } + + result = append(result, LockWaitWarning{ + WaitingPID: s.WaitingPID, + WaitingQuery: s.WaitingQuery, + BlockingPID: s.BlockingPID, + BlockingQuery: s.BlockingQuery, + LockedTable: s.LockedTable, + WaitAgeSeconds: s.WaitAgeSeconds, + Explanation: fmt.Sprintf( + "Session %d has been waiting %.1f seconds for a lock on table %q held by session %d. If session %d isn't about to commit or roll back on its own, this is worth investigating — every session queued behind it is stalled for as long as it stays open.", + s.WaitingPID, s.WaitAgeSeconds, s.LockedTable, s.BlockingPID, s.BlockingPID, + ), + }) + } + return result +} diff --git a/go/internal/domain/lock_wait_test.go b/go/internal/domain/lock_wait_test.go new file mode 100644 index 0000000..8ab84c1 --- /dev/null +++ b/go/internal/domain/lock_wait_test.go @@ -0,0 +1,97 @@ +package domain + +import "testing" + +func TestDetectLockWaitWarnings(t *testing.T) { + tests := []struct { + name string + sessions []LockWaitSession + want []int32 // WaitingPIDs expected in the result, in order + }{ + { + name: "below the warning threshold is ignored", + sessions: []LockWaitSession{ + {WaitingPID: 1, BlockingPID: 2, LockedTable: "orders", WaitAgeSeconds: 1}, + }, + want: nil, + }, + { + name: "exactly at the warning threshold qualifies", + sessions: []LockWaitSession{ + {WaitingPID: 3, BlockingPID: 4, LockedTable: "orders", WaitAgeSeconds: LockWaitWarningSeconds}, + }, + want: []int32{3}, + }, + { + name: "above the warning threshold qualifies", + sessions: []LockWaitSession{ + {WaitingPID: 5, BlockingPID: 6, LockedTable: "orders", WaitAgeSeconds: 60}, + }, + want: []int32{5}, + }, + { + name: "zero wait age is ignored", + sessions: []LockWaitSession{ + {WaitingPID: 7, BlockingPID: 8, LockedTable: "orders", WaitAgeSeconds: 0}, + }, + want: nil, + }, + { + name: "mixed input returns only the qualifying sessions, preserving input order", + sessions: []LockWaitSession{ + {WaitingPID: 10, BlockingPID: 20, LockedTable: "a", WaitAgeSeconds: 0.5}, + {WaitingPID: 11, BlockingPID: 21, LockedTable: "b", WaitAgeSeconds: 30}, + {WaitingPID: 12, BlockingPID: 22, LockedTable: "c", WaitAgeSeconds: 2}, + {WaitingPID: 13, BlockingPID: 23, LockedTable: "d", WaitAgeSeconds: 120}, + }, + want: []int32{11, 13}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectLockWaitWarnings(tt.sessions) + + if len(got) != len(tt.want) { + t.Fatalf("DetectLockWaitWarnings() returned %d warnings, want %d (%+v)", len(got), len(tt.want), got) + } + for i, pid := range tt.want { + if got[i].WaitingPID != pid { + t.Errorf("warning[%d].WaitingPID = %d, want %d", i, got[i].WaitingPID, pid) + } + } + }) + } +} + +func TestDetectLockWaitWarnings_NeverReturnsNilSlice(t *testing.T) { + got := DetectLockWaitWarnings(nil) + if got == nil { + t.Fatal("DetectLockWaitWarnings(nil) returned a nil slice, want an empty non-nil slice") + } + if len(got) != 0 { + t.Fatalf("DetectLockWaitWarnings(nil) = %v, want empty", got) + } +} + +func TestDetectLockWaitWarnings_Explanation(t *testing.T) { + got := DetectLockWaitWarnings([]LockWaitSession{ + { + WaitingPID: 42, + WaitingQuery: "UPDATE orders SET status = ? WHERE id = ?", + BlockingPID: 99, + BlockingQuery: "UPDATE orders SET status = ? WHERE id = ?", + LockedTable: "orders", + WaitAgeSeconds: 45, + }, + }) + + if len(got) != 1 { + t.Fatalf("expected exactly 1 warning, got %d", len(got)) + } + for _, want := range []string{"42", "99", "orders", "45.0"} { + if !contains(got[0].Explanation, want) { + t.Errorf("Explanation = %q, want it to contain %q", got[0].Explanation, want) + } + } +} diff --git a/go/internal/domain/unlogged_table.go b/go/internal/domain/unlogged_table.go index 8eede9c..ec8bd9e 100644 --- a/go/internal/domain/unlogged_table.go +++ b/go/internal/domain/unlogged_table.go @@ -22,3 +22,18 @@ func NewUnloggedTable(table string) UnloggedTable { ), } } + +// NewMemoryEngineTable carries the same finding as NewUnloggedTable — a +// table that isn't crash-safe — but with wording specific to MySQL's +// MEMORY engine, which has no write-ahead log at all: MEMORY tables live +// entirely in RAM and are silently emptied on any restart, not just an +// unclean shutdown. +func NewMemoryEngineTable(table string) UnloggedTable { + return UnloggedTable{ + Table: table, + Explanation: fmt.Sprintf( + "Table %q uses the MEMORY storage engine. Its data lives entirely in RAM and is never written to disk, so it's silently emptied on any server restart, not just a crash. Confirm this is intentional — if this table holds anything you'd need after a restart, consider using a regular (InnoDB) table instead.", + table, + ), + } +} diff --git a/go/internal/domain/unused_index.go b/go/internal/domain/unused_index.go index c0cc2d4..59ad151 100644 --- a/go/internal/domain/unused_index.go +++ b/go/internal/domain/unused_index.go @@ -61,6 +61,23 @@ func buildUnusedExplanation(idx UnusedIndexInfo, statsAgeSeconds float64) string ) } +// NewUnusedIndex builds an UnusedIndex from a determination the source +// engine already made itself (e.g. MySQL's sys.schema_unused_indexes, +// which flags an index as unused based on zero I/O since the server +// started or performance_schema counters were last reset — MySQL doesn't +// expose the scan count or a simple per-index size the way Postgres does, +// so those fields are left at zero here rather than guessed at). +func NewUnusedIndex(table, index string) UnusedIndex { + return UnusedIndex{ + Table: table, + Index: index, + Explanation: fmt.Sprintf( + "Index %q on %s has had zero reads since the server started (or since statistics were last reset). It may be safe to drop, but verify it isn't used by a rare batch job or reporting query before doing so.", + index, table, + ), + } +} + func formatBytes(bytes int64) string { const unit = 1024 if bytes < unit { diff --git a/go/internal/infrastructure/config/config.go b/go/internal/infrastructure/config/config.go index 8e56d61..5ba60b8 100644 --- a/go/internal/infrastructure/config/config.go +++ b/go/internal/infrastructure/config/config.go @@ -11,6 +11,7 @@ import ( // environment variables. No defaults are silently assumed for secrets. type Config struct { DatabaseURL string + Engine DBEngine HTTPPort string PollInterval time.Duration APIKey string @@ -23,6 +24,17 @@ type Config struct { HistoryMaxDBSizeBytes int64 } +// DBEngine selects which database engine pgscope connects to and collects +// stats from. Each engine gets its own infrastructure adapter package +// implementing the same application-layer ports — domain and application +// code never branches on this value, only main.go's composition root does. +type DBEngine string + +const ( + DBEnginePostgres DBEngine = "postgres" + DBEngineMySQL DBEngine = "mysql" +) + const ( defaultPollIntervalSeconds = 1 defaultInsightsRateLimitPerSecond = 5 @@ -32,6 +44,7 @@ const ( defaultHistoryRecordIntervalSeconds = 15 defaultHistoryMaxSessionsPerSnapshot = 50 defaultHistoryMaxDBSizeMB = 500 + defaultDBEngine = DBEnginePostgres ) func Load() (Config, error) { @@ -40,6 +53,11 @@ func Load() (Config, error) { return Config{}, fmt.Errorf("PGSCOPE_DATABASE_URL is required") } + engine, err := loadDBEngine() + if err != nil { + return Config{}, err + } + apiKey := os.Getenv("PGSCOPE_API_KEY") if apiKey == "" { return Config{}, fmt.Errorf("PGSCOPE_API_KEY is required") @@ -92,6 +110,7 @@ func Load() (Config, error) { return Config{ DatabaseURL: databaseURL, + Engine: engine, HTTPPort: httpPort, PollInterval: pollInterval, APIKey: apiKey, @@ -226,3 +245,20 @@ func loadHistoryMaxDBSizeBytes() (int64, error) { return megabytes * 1024 * 1024, nil } + +// loadDBEngine selects which database engine to connect to. Defaults to +// postgres — the only engine currently implemented — so existing setups +// don't need a new env var to keep working. +func loadDBEngine() (DBEngine, error) { + raw := os.Getenv("PGSCOPE_DB_ENGINE") + if raw == "" { + return defaultDBEngine, nil + } + + switch DBEngine(raw) { + case DBEnginePostgres, DBEngineMySQL: + return DBEngine(raw), nil + default: + return "", fmt.Errorf("PGSCOPE_DB_ENGINE must be one of: postgres, mysql, got %q", raw) + } +} diff --git a/go/internal/infrastructure/mysql/auto_increment_collector.go b/go/internal/infrastructure/mysql/auto_increment_collector.go new file mode 100644 index 0000000..54e63ee --- /dev/null +++ b/go/internal/infrastructure/mysql/auto_increment_collector.go @@ -0,0 +1,116 @@ +package mysql + +import ( + "context" + "database/sql" + "math" + "strings" + + "github.com/fayupable/pgscope/internal/domain" +) + +// autoIncrementQuery reads every table's current AUTO_INCREMENT value +// alongside its column's declared type — MySQL has no separate sequence +// object outside MariaDB, the equivalent overflow risk lives on the +// auto-incrementing column itself. AUTO_INCREMENT here is the next value +// to be assigned, close enough to "current usage" for this purpose (same +// caveat Postgres's own sequence last_value carries). +// +// Caveat verified empirically: unlike Postgres's pg_sequences.last_value, +// information_schema.TABLES.AUTO_INCREMENT is served from InnoDB's +// persistent statistics cache and is not always live — it can lag behind +// the real value until InnoDB refreshes it (this happens automatically +// once roughly 10% of a table's rows change, via innodb_stats_auto_recalc, +// or immediately after an explicit ANALYZE TABLE). In practice this means +// the reported value may occasionally under-report how close a table is +// to overflow on a table that changes rarely. +const autoIncrementQuery = ` +SELECT + t.TABLE_NAME, + t.AUTO_INCREMENT, + c.DATA_TYPE, + c.COLUMN_TYPE +FROM information_schema.TABLES t +JOIN information_schema.COLUMNS c + ON c.TABLE_SCHEMA = t.TABLE_SCHEMA AND c.TABLE_NAME = t.TABLE_NAME +WHERE t.TABLE_SCHEMA = DATABASE() + AND t.AUTO_INCREMENT IS NOT NULL + AND c.EXTRA LIKE '%auto_increment%' +` + +// AutoIncrementCollector reads AUTO_INCREMENT usage directly from the +// system catalog. It has no opinion on what's "too close" to the limit — +// that judgment belongs to domain.DetectSequenceOverflowRisks, unchanged +// from the Postgres adapter (MySQL's AUTO_INCREMENT columns are mapped +// onto the same engine-agnostic domain.SequenceUsage shape). +type AutoIncrementCollector struct { + db *sql.DB +} + +func NewAutoIncrementCollector(db *sql.DB) *AutoIncrementCollector { + return &AutoIncrementCollector{db: db} +} + +func (c *AutoIncrementCollector) Fetch(ctx context.Context) ([]domain.SequenceUsage, error) { + rows, err := c.db.QueryContext(ctx, autoIncrementQuery) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + usages := make([]domain.SequenceUsage, 0) + for rows.Next() { + var table, dataType, columnType string + var current int64 + if err := rows.Scan(&table, ¤t, &dataType, &columnType); err != nil { + return nil, err + } + + usages = append(usages, domain.SequenceUsage{ + Sequence: table, + CurrentValue: current, + MaxValue: maxValueFor(dataType, strings.Contains(columnType, "unsigned")), + }) + } + + return usages, rows.Err() +} + +// maxValueFor returns the largest value an AUTO_INCREMENT column of this +// MySQL integer type can hold. BIGINT UNSIGNED's true maximum +// (18446744073709551615) exceeds int64's range — domain.SequenceUsage +// uses int64 to match Postgres's sequence values, so it's capped at +// math.MaxInt64 here, a practical simplification: a table realistically +// reaching even that capped value is already deep in overflow territory +// by any definition. +func maxValueFor(dataType string, unsigned bool) int64 { + switch strings.ToLower(dataType) { + case "tinyint": + if unsigned { + return 255 + } + return 127 + case "smallint": + if unsigned { + return 65535 + } + return 32767 + case "mediumint": + if unsigned { + return 16777215 + } + return 8388607 + case "int": + if unsigned { + return 4294967295 + } + return 2147483647 + case "bigint": + if unsigned { + return math.MaxInt64 + } + return math.MaxInt64 + default: + return math.MaxInt64 + } +} diff --git a/go/internal/infrastructure/mysql/column_pattern_matcher.go b/go/internal/infrastructure/mysql/column_pattern_matcher.go new file mode 100644 index 0000000..1b0df4d --- /dev/null +++ b/go/internal/infrastructure/mysql/column_pattern_matcher.go @@ -0,0 +1,79 @@ +package mysql + +import ( + "sort" + "strings" +) + +const suspectedColumnLimit = 2 + +// extractSuspectedColumns tallies which column names appear most often in +// a "column = ?"-shaped comparison across query texts already known to +// reference the candidate table. Same approach as the Postgres adapter's +// extractSuspectedColumns, but matching MySQL's DIGEST_TEXT placeholder +// (?) instead of Postgres's ($1, $2, ...). Best-effort and advisory — +// pattern matching over already-masked query text, never literal values. +func extractSuspectedColumns(queryTexts []string) []string { + columnCounts := make(map[string]int) + + for _, text := range queryTexts { + for _, column := range findComparedColumns(text) { + columnCounts[column]++ + } + } + + return topColumns(columnCounts, suspectedColumnLimit) +} + +func findComparedColumns(text string) []string { + comparisonOps := []string{"=", ">", "<", ">=", "<="} + fields := strings.Fields(text) + columns := make([]string, 0) + + for i := 0; i+2 < len(fields); i++ { + if !isComparisonOp(fields[i+1], comparisonOps) { + continue + } + if fields[i+2] != "?" { + continue + } + columns = append(columns, strings.ToLower(strings.Trim(fields[i], "`"))) + } + + return columns +} + +func isComparisonOp(token string, ops []string) bool { + for _, op := range ops { + if token == op { + return true + } + } + return false +} + +func topColumns(counts map[string]int, limit int) []string { + type entry struct { + column string + count int + } + + entries := make([]entry, 0, len(counts)) + for column, count := range counts { + entries = append(entries, entry{column, count}) + } + + sort.Slice(entries, func(i, j int) bool { + if entries[i].count != entries[j].count { + return entries[i].count > entries[j].count + } + return entries[i].column < entries[j].column + }) + + result := make([]string, 0, limit) + for i := 0; i < len(entries) && i < limit; i++ { + result = append(result, entries[i].column) + } + + return result +} diff --git a/go/internal/infrastructure/mysql/connection_saturation_collector.go b/go/internal/infrastructure/mysql/connection_saturation_collector.go new file mode 100644 index 0000000..7d5388e --- /dev/null +++ b/go/internal/infrastructure/mysql/connection_saturation_collector.go @@ -0,0 +1,52 @@ +package mysql + +import ( + "context" + "database/sql" + + "github.com/fayupable/pgscope/internal/domain" +) + +// performance_schema.global_status / global_variables are used instead of +// `SHOW STATUS LIKE ?` / `SHOW VARIABLES LIKE ?` — the SHOW statement +// grammar doesn't accept a placeholder argument the way a normal SELECT +// does (the driver sends a literal "?" the server can't parse), while +// these are just regular queryable tables. +const statusQuery = `SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = ?` +const variableQuery = `SELECT VARIABLE_VALUE FROM performance_schema.global_variables WHERE VARIABLE_NAME = ?` + +// ConnectionSaturationCollector reads active connection count and the +// configured ceiling directly from MySQL's status/variable views — a +// purely descriptive fact with no judgment attached, same role as its +// Postgres counterpart. Judgment (when this is "too high") belongs to +// domain.NewConnectionSaturation, which is engine-agnostic and already +// shared with the Postgres adapter. +type ConnectionSaturationCollector struct { + db *sql.DB +} + +func NewConnectionSaturationCollector(db *sql.DB) *ConnectionSaturationCollector { + return &ConnectionSaturationCollector{db: db} +} + +func (c *ConnectionSaturationCollector) Fetch(ctx context.Context) (domain.ConnectionSaturation, error) { + active, err := c.readInt(ctx, statusQuery, "Threads_connected") + if err != nil { + return domain.ConnectionSaturation{}, err + } + + maxConns, err := c.readInt(ctx, variableQuery, "max_connections") + if err != nil { + return domain.ConnectionSaturation{}, err + } + + return domain.NewConnectionSaturation(active, maxConns), nil +} + +func (c *ConnectionSaturationCollector) readInt(ctx context.Context, query, name string) (int, error) { + var value int + if err := c.db.QueryRowContext(ctx, query, name).Scan(&value); err != nil { + return 0, err + } + return value, nil +} diff --git a/go/internal/infrastructure/mysql/database_size_collector.go b/go/internal/infrastructure/mysql/database_size_collector.go new file mode 100644 index 0000000..e3a02f4 --- /dev/null +++ b/go/internal/infrastructure/mysql/database_size_collector.go @@ -0,0 +1,75 @@ +package mysql + +import ( + "context" + "database/sql" + + "github.com/fayupable/pgscope/internal/domain" +) + +const largestTablesLimit = 10 + +// databaseSizeQuery sums each table's on-disk footprint (data + indexes) +// for the current schema — MySQL has no single built-in function like +// Postgres's pg_database_size, so this is computed the same way +// largestTablesQuery derives per-table size, just summed. +const databaseSizeQuery = ` +SELECT COALESCE(SUM(DATA_LENGTH + INDEX_LENGTH), 0) +FROM information_schema.TABLES +WHERE TABLE_SCHEMA = DATABASE() +` + +const largestTablesQuery = ` +SELECT TABLE_NAME, (DATA_LENGTH + INDEX_LENGTH) AS total_bytes +FROM information_schema.TABLES +WHERE TABLE_SCHEMA = DATABASE() +ORDER BY total_bytes DESC +LIMIT ? +` + +// DatabaseSizeCollector reads overall and per-table storage size directly +// from information_schema — a purely descriptive fact with no judgment +// attached, same role as its Postgres counterpart. +type DatabaseSizeCollector struct { + db *sql.DB +} + +func NewDatabaseSizeCollector(db *sql.DB) *DatabaseSizeCollector { + return &DatabaseSizeCollector{db: db} +} + +func (c *DatabaseSizeCollector) Fetch(ctx context.Context) (domain.DatabaseSizeInfo, error) { + var totalBytes int64 + if err := c.db.QueryRowContext(ctx, databaseSizeQuery).Scan(&totalBytes); err != nil { + return domain.DatabaseSizeInfo{}, err + } + + tables, err := c.fetchLargestTables(ctx) + if err != nil { + return domain.DatabaseSizeInfo{}, err + } + + return domain.DatabaseSizeInfo{ + TotalBytes: totalBytes, + LargestTables: tables, + }, nil +} + +func (c *DatabaseSizeCollector) fetchLargestTables(ctx context.Context) ([]domain.TableSize, error) { + rows, err := c.db.QueryContext(ctx, largestTablesQuery, largestTablesLimit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + tables := make([]domain.TableSize, 0) + for rows.Next() { + var t domain.TableSize + if err := rows.Scan(&t.Table, &t.TotalBytes); err != nil { + return nil, err + } + tables = append(tables, t) + } + + return tables, rows.Err() +} diff --git a/go/internal/infrastructure/mysql/database_stats_collector.go b/go/internal/infrastructure/mysql/database_stats_collector.go new file mode 100644 index 0000000..7d3ad86 --- /dev/null +++ b/go/internal/infrastructure/mysql/database_stats_collector.go @@ -0,0 +1,147 @@ +package mysql + +import ( + "context" + "database/sql" + "sync" + "time" + + "github.com/fayupable/pgscope/internal/domain" +) + +// databaseStatsQuery reads four cumulative counters from +// performance_schema.global_status in a single round trip. Com_commit / +// Com_rollback are MySQL's equivalent of Postgres's xact_commit / +// xact_rollback; Innodb_buffer_pool_read_requests / Innodb_buffer_pool_reads +// are the InnoDB buffer pool's logical vs. physical reads, MySQL's +// equivalent of Postgres's blks_hit / blks_read cache hit ratio. MySQL has +// no single counter for temp *bytes* the way Postgres's temp_bytes is — +// Created_tmp_disk_tables is a count, not a byte size, so temp bytes/sec +// is left at zero here rather than reporting a misleading unit. +const databaseStatsQuery = ` +SELECT VARIABLE_NAME, VARIABLE_VALUE +FROM performance_schema.global_status +WHERE VARIABLE_NAME IN ( + 'Com_commit', + 'Com_rollback', + 'Innodb_buffer_pool_read_requests', + 'Innodb_buffer_pool_reads' +) +` + +// DatabaseStatsCollector implements the same rate-tracking pattern as its +// Postgres counterpart: the counters above are cumulative since the server +// started, not meaningful to display live on their own, so this adapter +// keeps the previous measurement in memory and reports the rate of change +// between calls. +type DatabaseStatsCollector struct { + db *sql.DB + + mu sync.Mutex + hasBaseline bool + lastCommits int64 + lastRollback int64 + lastMeasured time.Time +} + +func NewDatabaseStatsCollector(db *sql.DB) *DatabaseStatsCollector { + return &DatabaseStatsCollector{db: db} +} + +func (c *DatabaseStatsCollector) FetchDatabaseStats(ctx context.Context) (domain.DatabaseActivityStats, error) { + counters, err := c.queryCounters(ctx) + if err != nil { + return domain.DatabaseActivityStats{}, err + } + + stats := c.computeRate(counters.commits, counters.rollbacks) + stats.CacheHitRatio = cacheHitRatio(counters.bufferPoolReadRequests, counters.bufferPoolReads) + return stats, nil +} + +type globalStatusCounters struct { + commits int64 + rollbacks int64 + bufferPoolReadRequests int64 + bufferPoolReads int64 +} + +func (c *DatabaseStatsCollector) queryCounters(ctx context.Context) (globalStatusCounters, error) { + rows, err := c.db.QueryContext(ctx, databaseStatsQuery) + if err != nil { + return globalStatusCounters{}, err + } + defer func() { _ = rows.Close() }() + + var counters globalStatusCounters + for rows.Next() { + var name string + var value int64 + if err := rows.Scan(&name, &value); err != nil { + return globalStatusCounters{}, err + } + switch name { + case "Com_commit": + counters.commits = value + case "Com_rollback": + counters.rollbacks = value + case "Innodb_buffer_pool_read_requests": + counters.bufferPoolReadRequests = value + case "Innodb_buffer_pool_reads": + counters.bufferPoolReads = value + } + } + + return counters, rows.Err() +} + +func (c *DatabaseStatsCollector) computeRate(commits, rollbacks int64) domain.DatabaseActivityStats { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + + if !c.hasBaseline { + c.setBaseline(commits, rollbacks, now) + return domain.DatabaseActivityStats{MeasuredAt: now} + } + + elapsed := now.Sub(c.lastMeasured).Seconds() + stats := domain.DatabaseActivityStats{ + CommitsPerSecond: rate(commits-c.lastCommits, elapsed), + RollbacksPerSecond: rate(rollbacks-c.lastRollback, elapsed), + MeasuredAt: now, + } + + c.setBaseline(commits, rollbacks, now) + return stats +} + +func (c *DatabaseStatsCollector) setBaseline(commits, rollbacks int64, at time.Time) { + c.hasBaseline = true + c.lastCommits = commits + c.lastRollback = rollbacks + c.lastMeasured = at +} + +func rate(delta int64, elapsedSeconds float64) float64 { + if elapsedSeconds <= 0 { + return 0 + } + return float64(delta) / elapsedSeconds +} + +// cacheHitRatio reports the fraction of InnoDB buffer pool reads served +// from memory rather than disk, as a 0-100 percentage — same shape and +// same "no reads at all is a perfect ratio" convention as the Postgres +// adapter's identical helper. +func cacheHitRatio(readRequests, physicalReads int64) float64 { + if readRequests == 0 { + return 100 + } + hitRatio := 100 * float64(readRequests-physicalReads) / float64(readRequests) + if hitRatio < 0 { + return 0 + } + return hitRatio +} diff --git a/go/internal/infrastructure/mysql/duplicate_index_collector.go b/go/internal/infrastructure/mysql/duplicate_index_collector.go new file mode 100644 index 0000000..6c92364 --- /dev/null +++ b/go/internal/infrastructure/mysql/duplicate_index_collector.go @@ -0,0 +1,75 @@ +package mysql + +import ( + "context" + "database/sql" + "strings" + + "github.com/fayupable/pgscope/internal/domain" +) + +// duplicateIndexQuery reads MySQL's own redundant-index analysis from +// sys.schema_redundant_indexes — unlike the Postgres adapter, no column- +// prefix comparison needs to happen here, MySQL's sys schema has already +// determined which index is redundant against which. Requires SELECT +// granted on the sys schema (see go/README.md's MySQL setup instructions). +const duplicateIndexQuery = ` +SELECT + table_name, + redundant_index_name, + redundant_index_columns, + dominant_index_name, + dominant_index_columns +FROM sys.schema_redundant_indexes +WHERE table_schema = DATABASE() +` + +// DuplicateIndexCollector reads MySQL's precomputed redundant-index +// findings from the sys schema. No further judgment is needed here — see +// domain.NewDuplicateIndex, which only formats the explanation text. +type DuplicateIndexCollector struct { + db *sql.DB +} + +func NewDuplicateIndexCollector(db *sql.DB) *DuplicateIndexCollector { + return &DuplicateIndexCollector{db: db} +} + +func (c *DuplicateIndexCollector) Fetch(ctx context.Context) ([]domain.DuplicateIndex, error) { + rows, err := c.db.QueryContext(ctx, duplicateIndexQuery) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + result := make([]domain.DuplicateIndex, 0) + for rows.Next() { + var table, redundantIndex, redundantColumns, dominantIndex, dominantColumns string + if err := rows.Scan(&table, &redundantIndex, &redundantColumns, &dominantIndex, &dominantColumns); err != nil { + return nil, err + } + + result = append(result, domain.NewDuplicateIndex( + table, + redundantIndex, + dominantIndex, + splitColumns(redundantColumns), + splitColumns(dominantColumns), + )) + } + + return result, rows.Err() +} + +// splitColumns parses sys.schema_redundant_indexes' comma-separated column +// list (e.g. "col_a,col_b") into a slice, trimming any incidental spaces. +func splitColumns(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + for i, p := range parts { + parts[i] = strings.TrimSpace(p) + } + return parts +} diff --git a/go/internal/infrastructure/mysql/idle_in_transaction_collector.go b/go/internal/infrastructure/mysql/idle_in_transaction_collector.go new file mode 100644 index 0000000..3440960 --- /dev/null +++ b/go/internal/infrastructure/mysql/idle_in_transaction_collector.go @@ -0,0 +1,59 @@ +package mysql + +import ( + "context" + "database/sql" + + "github.com/fayupable/pgscope/internal/domain" +) + +// idleInTransactionQuery reads sessions with an open InnoDB transaction +// that isn't currently running a query (trx_query IS NULL) — MySQL's +// closest equivalent to Postgres's state = 'idle in transaction'. Owner +// and application come from a left join to performance_schema.threads, +// since innodb_trx alone doesn't carry the connecting user (MySQL has no +// application_name equivalent, so the connecting database is used there +// instead — still useful for identifying which client left this open). +const idleInTransactionQuery = ` +SELECT + t.trx_mysql_thread_id, + COALESCE(th.PROCESSLIST_USER, ''), + COALESCE(th.PROCESSLIST_DB, ''), + TIMESTAMPDIFF(SECOND, t.trx_started, NOW()) +FROM information_schema.innodb_trx t +LEFT JOIN performance_schema.threads th ON th.PROCESSLIST_ID = t.trx_mysql_thread_id +WHERE t.trx_state = 'RUNNING' AND t.trx_query IS NULL +` + +// IdleInTransactionCollector reads open-but-idle InnoDB transactions from +// information_schema.innodb_trx. It has no opinion on how long is "too +// long" — that judgment belongs to domain.DetectIdleInTransactionWarnings, +// unchanged from the Postgres adapter. +type IdleInTransactionCollector struct { + db *sql.DB +} + +func NewIdleInTransactionCollector(db *sql.DB) *IdleInTransactionCollector { + return &IdleInTransactionCollector{db: db} +} + +func (c *IdleInTransactionCollector) Fetch(ctx context.Context) ([]domain.IdleInTransactionSession, error) { + rows, err := c.db.QueryContext(ctx, idleInTransactionQuery) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + sessions := make([]domain.IdleInTransactionSession, 0) + for rows.Next() { + var threadID int64 + var s domain.IdleInTransactionSession + if err := rows.Scan(&threadID, &s.User, &s.ApplicationName, &s.IdleSeconds); err != nil { + return nil, err + } + s.PID = int32(threadID) + sessions = append(sessions, s) + } + + return sessions, rows.Err() +} diff --git a/go/internal/infrastructure/mysql/index_candidate_builder.go b/go/internal/infrastructure/mysql/index_candidate_builder.go new file mode 100644 index 0000000..00de1b9 --- /dev/null +++ b/go/internal/infrastructure/mysql/index_candidate_builder.go @@ -0,0 +1,65 @@ +package mysql + +import ( + "context" + + "github.com/fayupable/pgscope/internal/domain" +) + +// FetchIndexCandidates ties together IndexCandidateCollector's raw scan +// stats with QueryTextCollector's per-table query shapes to build fully +// judged domain.IndexCandidate suggestions — the same three-step flow +// the Postgres adapter's InsightsCollector.fetchIndexCandidates performs. +// +// Unlike the Postgres adapter, no selectivity (domain.IndexSignal.NDistinct) +// is set here: MySQL keeps no free, precomputed cardinality statistic for +// an unindexed column the way Postgres's pg_stats.n_distinct does. The +// only way to get one would be a live SELECT COUNT(DISTINCT column) +// against the actual table, which could be expensive on a large table +// this tool has no business slowing down. Selectivity is left unknown +// (NDistinct stays nil) — domain.IndexSignal already treats that as a +// valid, handled case, so candidates are still built, just without the +// extra "how selective is this column" refinement Postgres provides. +func FetchIndexCandidates( + ctx context.Context, + candidates *IndexCandidateCollector, + queryTexts *QueryTextCollector, + minRows int64, + maxIndexUsagePercent float64, +) ([]domain.IndexCandidate, error) { + raw, err := candidates.FetchRaw(ctx, minRows, maxIndexUsagePercent) + if err != nil { + return nil, err + } + if len(raw) == 0 { + return []domain.IndexCandidate{}, nil + } + + elapsedSeconds := candidates.BeginCycle() + + result := make([]domain.IndexCandidate, 0, len(raw)) + for _, rc := range raw { + texts, err := queryTexts.FetchForTable(ctx, rc.Table) + if err != nil { + return nil, err + } + + suspectedColumns := extractSuspectedColumns(texts) + + signal := domain.IndexSignal{ + EstimatedRows: rc.EstimatedRows, + SeqScan: rc.SeqScan, + IdxScan: rc.IdxScan, + } + + result = append(result, domain.NewIndexCandidate( + rc.Table, + signal, + suspectedColumns, + rc.IndexCount, + candidates.WritesPerSecond(rc.Table, rc.WriteOps, elapsedSeconds), + )) + } + + return result, nil +} diff --git a/go/internal/infrastructure/mysql/index_candidate_collector.go b/go/internal/infrastructure/mysql/index_candidate_collector.go new file mode 100644 index 0000000..7c84642 --- /dev/null +++ b/go/internal/infrastructure/mysql/index_candidate_collector.go @@ -0,0 +1,125 @@ +package mysql + +import ( + "context" + "database/sql" + "sync" + "time" +) + +const indexCandidateLimit = 10 + +// indexCandidateTablesQuery is MySQL's equivalent of the Postgres adapter's +// pg_stat_user_tables read. performance_schema.table_io_waits_summary_by_index_usage +// breaks table access down per index, with one special row per table where +// INDEX_NAME IS NULL — that row represents access that didn't go through +// any index at all, MySQL's equivalent of a Postgres sequential scan. +// Summing COUNT_FETCH across the NULL-index row vs every named-index row +// gives the same seq-scan-vs-idx-scan signal domain.IndexSignal expects. +// TABLE_ROWS from information_schema.TABLES is an estimate, same caveat +// Postgres's n_live_tup carries. +const indexCandidateTablesQuery = ` +SELECT + w.OBJECT_NAME, + SUM(CASE WHEN w.INDEX_NAME IS NULL THEN w.COUNT_FETCH ELSE 0 END) AS seq_scan, + SUM(CASE WHEN w.INDEX_NAME IS NOT NULL THEN w.COUNT_FETCH ELSE 0 END) AS idx_scan, + MAX(t.TABLE_ROWS) AS estimated_rows, + SUM(w.COUNT_INSERT + w.COUNT_UPDATE) AS write_ops, + COUNT(DISTINCT w.INDEX_NAME) AS index_count +FROM performance_schema.table_io_waits_summary_by_index_usage w +JOIN information_schema.TABLES t + ON t.TABLE_SCHEMA = w.OBJECT_SCHEMA AND t.TABLE_NAME = w.OBJECT_NAME +WHERE w.OBJECT_SCHEMA = DATABASE() +GROUP BY w.OBJECT_NAME +HAVING idx_scan > 0 + AND estimated_rows >= ? + AND (100.0 * idx_scan / NULLIF(seq_scan + idx_scan, 0)) < ? +ORDER BY seq_scan DESC +LIMIT ? +` + +// RawIndexCandidate is the unjudged shape read from performance_schema — +// domain.IndexSignal decides what, if anything, these numbers mean. +type RawIndexCandidate struct { + Table string + SeqScan int64 + IdxScan int64 + EstimatedRows int64 + WriteOps int64 + IndexCount int +} + +// IndexCandidateCollector reads raw scan/write statistics per table. Like +// the MySQL DatabaseStatsCollector, it's stateful: WriteOps is a +// cumulative counter since the server started, not meaningful on its +// own, so this adapter tracks the previous measurement per table and +// reports a writes/second rate between calls. +type IndexCandidateCollector struct { + db *sql.DB + + mu sync.Mutex + lastWriteOps map[string]int64 + lastMeasured time.Time + hasBaseline bool +} + +func NewIndexCandidateCollector(db *sql.DB) *IndexCandidateCollector { + return &IndexCandidateCollector{ + db: db, + lastWriteOps: make(map[string]int64), + } +} + +func (c *IndexCandidateCollector) FetchRaw(ctx context.Context, minRows int64, maxIndexUsagePercent float64) ([]RawIndexCandidate, error) { + rows, err := c.db.QueryContext(ctx, indexCandidateTablesQuery, minRows, maxIndexUsagePercent, indexCandidateLimit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + raw := make([]RawIndexCandidate, 0) + for rows.Next() { + var rc RawIndexCandidate + if err := rows.Scan(&rc.Table, &rc.SeqScan, &rc.IdxScan, &rc.EstimatedRows, &rc.WriteOps, &rc.IndexCount); err != nil { + return nil, err + } + raw = append(raw, rc) + } + + return raw, rows.Err() +} + +// BeginCycle marks the start of one fetch cycle and returns the elapsed +// time since the previous cycle, to be reused across every table's +// WritesPerSecond call within this cycle. Calling it once per cycle +// (rather than once per table) prevents the second table measured in +// the same cycle from seeing a near-zero elapsed time against the first. +func (c *IndexCandidateCollector) BeginCycle() (elapsedSeconds float64) { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + if c.hasBaseline { + elapsedSeconds = now.Sub(c.lastMeasured).Seconds() + } + c.lastMeasured = now + c.hasBaseline = true + return elapsedSeconds +} + +// WritesPerSecond returns the rate of change in a table's write_ops +// counter since the previous cycle, using the elapsed time BeginCycle +// reported for the current cycle. A table seen for the first time has +// nothing to compare against, so it returns 0 and records a baseline. +func (c *IndexCandidateCollector) WritesPerSecond(table string, writeOps int64, elapsedSeconds float64) float64 { + c.mu.Lock() + defer c.mu.Unlock() + + previous, seen := c.lastWriteOps[table] + c.lastWriteOps[table] = writeOps + + if !seen || elapsedSeconds <= 0 { + return 0 + } + return float64(writeOps-previous) / elapsedSeconds +} diff --git a/go/internal/infrastructure/mysql/insights_collector.go b/go/internal/infrastructure/mysql/insights_collector.go new file mode 100644 index 0000000..b642fee --- /dev/null +++ b/go/internal/infrastructure/mysql/insights_collector.go @@ -0,0 +1,143 @@ +package mysql + +import ( + "context" + "database/sql" + + "github.com/fayupable/pgscope/internal/domain" +) + +// InsightsCollector implements output.IInsightsPort for MySQL/MariaDB by +// composing focused, single-purpose collectors, the same orchestration +// role the Postgres adapter's InsightsCollector plays. It holds no SQL +// itself. Every suggestion produced downstream is advisory — never a +// certainty, never an automatic action. +// +// domain.Insights is shared across engines, but MySQL doesn't implement +// every field the Postgres adapter does — some Postgres concepts have no +// MySQL equivalent (VACUUM, WAL checkpoints, replication slots) or no +// good one (pg_stat_kcache-style physical I/O, function cost tracking, +// invalid index/constraint catalog entries). Those fields are left at +// their zero value (empty slice, false, zero struct) rather than +// fabricated — the JSON output simply omits/empties them for this +// engine. +type InsightsCollector struct { + databaseSize *DatabaseSizeCollector + connectionSaturation *ConnectionSaturationCollector + preparedTransactions *PreparedTransactionCollector + duplicateIndexes *DuplicateIndexCollector + unusedIndexes *UnusedIndexCollector + idleInTransaction *IdleInTransactionCollector + topQueries *TopQueryCollector + longRunningQueries *LongRunningQueryCollector + autoIncrement *AutoIncrementCollector + unloggedTables *UnloggedTableCollector + pagination *PaginationCollector + indexCandidates *IndexCandidateCollector + queryTexts *QueryTextCollector + lockWaits *LockWaitCollector +} + +func NewInsightsCollector(db *sql.DB) *InsightsCollector { + return &InsightsCollector{ + databaseSize: NewDatabaseSizeCollector(db), + connectionSaturation: NewConnectionSaturationCollector(db), + preparedTransactions: NewPreparedTransactionCollector(db), + duplicateIndexes: NewDuplicateIndexCollector(db), + unusedIndexes: NewUnusedIndexCollector(db), + idleInTransaction: NewIdleInTransactionCollector(db), + topQueries: NewTopQueryCollector(db), + longRunningQueries: NewLongRunningQueryCollector(db), + autoIncrement: NewAutoIncrementCollector(db), + unloggedTables: NewUnloggedTableCollector(db), + pagination: NewPaginationCollector(db), + indexCandidates: NewIndexCandidateCollector(db), + queryTexts: NewQueryTextCollector(db), + lockWaits: NewLockWaitCollector(db), + } +} + +func (c *InsightsCollector) FetchInsights(ctx context.Context) (domain.Insights, error) { + databaseSize, err := c.databaseSize.Fetch(ctx) + if err != nil { + return domain.Insights{}, err + } + + connectionSaturation, err := c.connectionSaturation.Fetch(ctx) + if err != nil { + return domain.Insights{}, err + } + + preparedTransactions, err := c.preparedTransactions.Fetch(ctx) + if err != nil { + return domain.Insights{}, err + } + + duplicateIndexes, err := c.duplicateIndexes.Fetch(ctx) + if err != nil { + return domain.Insights{}, err + } + + unusedIndexes, err := c.unusedIndexes.Fetch(ctx) + if err != nil { + return domain.Insights{}, err + } + + idleInTransaction, err := c.idleInTransaction.Fetch(ctx) + if err != nil { + return domain.Insights{}, err + } + + topQueries, err := c.topQueries.Fetch(ctx) + if err != nil { + return domain.Insights{}, err + } + + longRunningQueries, err := c.longRunningQueries.Fetch(ctx) + if err != nil { + return domain.Insights{}, err + } + + autoIncrementUsages, err := c.autoIncrement.Fetch(ctx) + if err != nil { + return domain.Insights{}, err + } + + unloggedTables, err := c.unloggedTables.Fetch(ctx) + if err != nil { + return domain.Insights{}, err + } + + paginationSignals, err := c.pagination.FetchCandidates(ctx) + if err != nil { + return domain.Insights{}, err + } + + indexCandidates, err := FetchIndexCandidates( + ctx, c.indexCandidates, c.queryTexts, domain.MinRowsForSuggestion, domain.MaxIndexUsagePercent, + ) + if err != nil { + return domain.Insights{}, err + } + + lockWaits, err := c.lockWaits.Fetch(ctx) + if err != nil { + return domain.Insights{}, err + } + + return domain.Insights{ + TopQueries: topQueries, + IndexCandidates: indexCandidates, + DuplicateIndexes: duplicateIndexes, + UnusedIndexes: unusedIndexes, + PaginationWarnings: domain.DetectPaginationWarnings(paginationSignals), + DatabaseSize: databaseSize, + ConnectionSaturation: connectionSaturation, + SequenceOverflowRisks: domain.DetectSequenceOverflowRisks(autoIncrementUsages), + IdleInTransactionWarnings: domain.DetectIdleInTransactionWarnings(idleInTransaction), + PreparedTransactionWarnings: domain.DetectPreparedTransactionWarnings(preparedTransactions), + LongRunningQueryWarnings: domain.DetectLongRunningQueryWarnings(longRunningQueries), + UnloggedTables: unloggedTables, + LockWaitWarnings: domain.DetectLockWaitWarnings(lockWaits), + }, nil +} diff --git a/go/internal/infrastructure/mysql/lock_severity.go b/go/internal/infrastructure/mysql/lock_severity.go new file mode 100644 index 0000000..0a71c86 --- /dev/null +++ b/go/internal/infrastructure/mysql/lock_severity.go @@ -0,0 +1,47 @@ +package mysql + +import "github.com/fayupable/pgscope/internal/domain" + +// classifyLockSeverity maps MySQL's data_locks LOCK_TYPE/LOCK_MODE pair to +// the engine-agnostic domain.LockSeverity, this adapter's own version of +// what the Postgres adapter's classifyLockSeverity does for Postgres's lock +// vocabulary (see collector.go's comment there — this is exactly the +// MySQL-specific mapping it anticipated). +// +// LOCK_TYPE matters as much as LOCK_MODE here: MySQL's 'X' mode means two +// very different things depending on scope. A RECORD-level X lock (the +// ordinary per-row lock any UPDATE/DELETE/SELECT..FOR UPDATE takes) only +// conflicts with another lock on that same row — functionally equivalent +// to Postgres's RowExclusiveLock (SharedWrite), not a big deal, multiple +// sessions writing different rows proceed fine. A TABLE-level X lock (from +// DDL, LOCK TABLES, or a full-table scan needing to lock everything) blocks +// the entire table — that's Postgres's AccessExclusiveLock (Exclusive). +// LOCK_MODE also carries GAP-lock suffixes (e.g. "X,GAP", "X,REC_NOT_GAP") +// that don't change this classification — only the base mode before the +// comma does. +func classifyLockSeverity(lockType, lockMode string) domain.LockSeverity { + base := baseLockMode(lockMode) + + switch base { + case "S": + return domain.LockSeveritySharedRead + case "X": + if lockType == "TABLE" { + return domain.LockSeverityExclusive + } + return domain.LockSeveritySharedWrite + case "IS", "IX": + return domain.LockSeverityIntent + default: + return domain.LockSeverityUnknown + } +} + +func baseLockMode(lockMode string) string { + for i := 0; i < len(lockMode); i++ { + if lockMode[i] == ',' { + return lockMode[:i] + } + } + return lockMode +} diff --git a/go/internal/infrastructure/mysql/lock_wait_collector.go b/go/internal/infrastructure/mysql/lock_wait_collector.go new file mode 100644 index 0000000..72d06ac --- /dev/null +++ b/go/internal/infrastructure/mysql/lock_wait_collector.go @@ -0,0 +1,65 @@ +package mysql + +import ( + "context" + "database/sql" + + "github.com/fayupable/pgscope/internal/domain" +) + +// lockWaitQuery reads the blocking relationship from sys.innodb_lock_waits +// — who is waiting, who is blocking, on which table, for how long. Query +// text deliberately does NOT come from sys.innodb_lock_waits's own +// waiting_query/blocking_query columns: those carry the raw, unnormalized +// SQL text (literal values included), the same class of exposure already +// found and fixed in long_running_query_collector.go. Instead, both are +// joined by PID through performance_schema.threads to +// events_statements_current.DIGEST_TEXT, MySQL's normalized ($N-equivalent) +// query text. +const lockWaitQuery = ` +SELECT + w.waiting_pid, + COALESCE(wesc.DIGEST_TEXT, '[query not tracked]'), + w.blocking_pid, + COALESCE(besc.DIGEST_TEXT, '[query not tracked]'), + w.locked_table_name, + w.wait_age_secs +FROM sys.innodb_lock_waits w +LEFT JOIN performance_schema.threads wth ON wth.PROCESSLIST_ID = w.waiting_pid +LEFT JOIN performance_schema.events_statements_current wesc ON wesc.THREAD_ID = wth.THREAD_ID +LEFT JOIN performance_schema.threads bth ON bth.PROCESSLIST_ID = w.blocking_pid +LEFT JOIN performance_schema.events_statements_current besc ON besc.THREAD_ID = bth.THREAD_ID +` + +// LockWaitCollector reads InnoDB row-lock wait relationships from the sys +// schema. It has no opinion on how long a wait is "too long" — that +// judgment belongs to domain.DetectLockWaitWarnings. +type LockWaitCollector struct { + db *sql.DB +} + +func NewLockWaitCollector(db *sql.DB) *LockWaitCollector { + return &LockWaitCollector{db: db} +} + +func (c *LockWaitCollector) Fetch(ctx context.Context) ([]domain.LockWaitSession, error) { + rows, err := c.db.QueryContext(ctx, lockWaitQuery) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + sessions := make([]domain.LockWaitSession, 0) + for rows.Next() { + var waitingPID, blockingPID int64 + var s domain.LockWaitSession + if err := rows.Scan(&waitingPID, &s.WaitingQuery, &blockingPID, &s.BlockingQuery, &s.LockedTable, &s.WaitAgeSeconds); err != nil { + return nil, err + } + s.WaitingPID = int32(waitingPID) + s.BlockingPID = int32(blockingPID) + sessions = append(sessions, s) + } + + return sessions, rows.Err() +} diff --git a/go/internal/infrastructure/mysql/long_running_query_collector.go b/go/internal/infrastructure/mysql/long_running_query_collector.go new file mode 100644 index 0000000..cb13d4a --- /dev/null +++ b/go/internal/infrastructure/mysql/long_running_query_collector.go @@ -0,0 +1,65 @@ +package mysql + +import ( + "context" + "database/sql" + + "github.com/fayupable/pgscope/internal/domain" +) + +// longRunningQueryQuery reads sessions with an open InnoDB transaction that +// IS currently running a query (trx_query IS NOT NULL) — the inverse of +// idleInTransactionQuery's filter, which only differs by that one +// condition. Query text comes from events_statements_current.DIGEST_TEXT +// (parameter values replaced with ?), never from innodb_trx.trx_query +// directly — trx_query is MySQL's raw, unnormalized text, and using it +// would repeat the exact literal-value exposure bug already found and +// fixed in the Postgres adapter's equivalent collector. Owner comes from +// a left join to performance_schema.threads, same as the +// idle-in-transaction adapter. +const longRunningQueryQuery = ` +SELECT + t.trx_mysql_thread_id, + COALESCE(th.PROCESSLIST_USER, ''), + COALESCE(th.PROCESSLIST_DB, ''), + COALESCE(esc.DIGEST_TEXT, '[query not tracked]'), + TIMESTAMPDIFF(SECOND, t.trx_started, NOW()) +FROM information_schema.innodb_trx t +LEFT JOIN performance_schema.threads th ON th.PROCESSLIST_ID = t.trx_mysql_thread_id +LEFT JOIN performance_schema.events_statements_current esc ON esc.THREAD_ID = th.THREAD_ID +WHERE t.trx_state = 'RUNNING' AND t.trx_query IS NOT NULL +` + +// LongRunningQueryCollector reads sessions currently executing a query +// inside an open InnoDB transaction, from information_schema.innodb_trx. +// It has no opinion on how long is "too long" — that judgment belongs to +// domain.DetectLongRunningQueryWarnings, unchanged from the Postgres +// adapter. +type LongRunningQueryCollector struct { + db *sql.DB +} + +func NewLongRunningQueryCollector(db *sql.DB) *LongRunningQueryCollector { + return &LongRunningQueryCollector{db: db} +} + +func (c *LongRunningQueryCollector) Fetch(ctx context.Context) ([]domain.LongRunningQuerySession, error) { + rows, err := c.db.QueryContext(ctx, longRunningQueryQuery) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + sessions := make([]domain.LongRunningQuerySession, 0) + for rows.Next() { + var threadID int64 + var s domain.LongRunningQuerySession + if err := rows.Scan(&threadID, &s.User, &s.ApplicationName, &s.Query, &s.RunningSeconds); err != nil { + return nil, err + } + s.PID = int32(threadID) + sessions = append(sessions, s) + } + + return sessions, rows.Err() +} diff --git a/go/internal/infrastructure/mysql/pagination_collector.go b/go/internal/infrastructure/mysql/pagination_collector.go new file mode 100644 index 0000000..37b0f5e --- /dev/null +++ b/go/internal/infrastructure/mysql/pagination_collector.go @@ -0,0 +1,70 @@ +package mysql + +import ( + "context" + "database/sql" + + "github.com/fayupable/pgscope/internal/domain" +) + +const paginationCandidateLimit = 50 + +// paginationCandidatesQuery mirrors the Postgres adapter's filter: any +// normalized query shape containing OFFSET, ranked by call count rather +// than cost, so a cheap or rarely-ranked shape is just as visible as an +// expensive one. AVG_TIMER_WAIT/MAX_TIMER_WAIT are in picoseconds, +// converted to milliseconds (÷ 1e9) to match domain.PaginationSignal's +// unit. +// +// MySQL's events_statements_summary_by_digest has no stddev column — +// unlike pg_stat_statements.stddev_exec_time, which the Postgres adapter +// uses directly. As an approximation, (MAX_TIMER_WAIT - AVG_TIMER_WAIT) +// is passed into domain.PaginationSignal's StddevExecMs field: it isn't a +// true standard deviation, but it captures the same underlying signal — +// a query shape whose slowest run was far worse than its average run, +// consistent with deep-OFFSET pagination getting progressively slower. +const paginationCandidatesQuery = ` +SELECT + DIGEST_TEXT, + COUNT_STAR, + AVG_TIMER_WAIT / 1000000000, + (MAX_TIMER_WAIT - AVG_TIMER_WAIT) / 1000000000, + SUM_ROWS_SENT +FROM performance_schema.events_statements_summary_by_digest +WHERE SCHEMA_NAME = DATABASE() + AND DIGEST_TEXT LIKE '%OFFSET%' +ORDER BY COUNT_STAR DESC +LIMIT ? +` + +// PaginationCollector reads OFFSET-containing query statistics from +// performance_schema. It has no opinion on whether a pattern is worth +// warning about — that judgment belongs to domain.DetectPaginationWarnings, +// unchanged from the Postgres adapter. +type PaginationCollector struct { + db *sql.DB +} + +func NewPaginationCollector(db *sql.DB) *PaginationCollector { + return &PaginationCollector{db: db} +} + +func (c *PaginationCollector) FetchCandidates(ctx context.Context) ([]domain.PaginationSignal, error) { + rows, err := c.db.QueryContext(ctx, paginationCandidatesQuery, paginationCandidateLimit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + signals := make([]domain.PaginationSignal, 0) + for rows.Next() { + var s domain.PaginationSignal + if err := rows.Scan(&s.Query, &s.Calls, &s.MeanExecMs, &s.StddevExecMs, &s.Rows); err != nil { + return nil, err + } + s.ContainsOffset = true // guaranteed by the SQL filter above + signals = append(signals, s) + } + + return signals, rows.Err() +} diff --git a/go/internal/infrastructure/mysql/pool.go b/go/internal/infrastructure/mysql/pool.go new file mode 100644 index 0000000..588aed7 --- /dev/null +++ b/go/internal/infrastructure/mysql/pool.go @@ -0,0 +1,56 @@ +package mysql + +import ( + "context" + "database/sql" + "fmt" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +type PoolConfig struct { + DSN string +} + +// NewPool opens a MySQL connection pool. Read-only enforcement is expected +// at the database user's grant level (SELECT-only privileges) — the same +// approach the root README documents for the Postgres pgscope_agent role. +// MySQL has no session-wide read-only knob as simple as Postgres's +// default_transaction_read_only RuntimeParam, so that layer isn't +// duplicated here; the grant-level restriction is the actual enforcement. +func NewPool(ctx context.Context, cfg PoolConfig) (*sql.DB, error) { + db, err := sql.Open("mysql", cfg.DSN) + if err != nil { + return nil, fmt.Errorf("open mysql connection: %w", err) + } + + applyPoolLimits(db) + + if err := verifyConnectivity(ctx, db); err != nil { + _ = db.Close() + return nil, err + } + + return db, nil +} + +// applyPoolLimits mirrors the Postgres pool's sizing (see postgres/pool.go) +// — small and isolated, since this pool is shared by the continuous +// session/lock poller and the on-demand insights collector. +func applyPoolLimits(db *sql.DB) { + db.SetMaxOpenConns(3) + db.SetMaxIdleConns(1) + db.SetConnMaxIdleTime(30 * time.Second) + db.SetConnMaxLifetime(30 * time.Minute) +} + +func verifyConnectivity(ctx context.Context, db *sql.DB) error { + pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + + if err := db.PingContext(pingCtx); err != nil { + return fmt.Errorf("ping database: %w", err) + } + return nil +} diff --git a/go/internal/infrastructure/mysql/prepared_transaction_collector.go b/go/internal/infrastructure/mysql/prepared_transaction_collector.go new file mode 100644 index 0000000..472154a --- /dev/null +++ b/go/internal/infrastructure/mysql/prepared_transaction_collector.go @@ -0,0 +1,61 @@ +package mysql + +import ( + "context" + "database/sql" + "strconv" + + "github.com/fayupable/pgscope/internal/domain" +) + +// preparedTransactionQuery reads XA transactions still in the PREPARED +// state from information_schema.innodb_trx — MySQL's equivalent of +// pg_prepared_xacts. trx_id is used as the transaction identifier (a +// proper XA GID would require the XA RECOVER statement, which isn't a +// plain SELECT and so is out of scope for a read-only tool). Owner and +// database come from a left join to performance_schema.threads, since +// innodb_trx alone doesn't carry the connecting user/database. +const preparedTransactionQuery = ` +SELECT + t.trx_id, + COALESCE(th.PROCESSLIST_DB, ''), + COALESCE(th.PROCESSLIST_USER, ''), + TIMESTAMPDIFF(SECOND, t.trx_started, NOW()) +FROM information_schema.innodb_trx t +LEFT JOIN performance_schema.threads th ON th.PROCESSLIST_ID = t.trx_mysql_thread_id +WHERE t.trx_state = 'PREPARED' +` + +// PreparedTransactionCollector reads orphaned XA (two-phase-commit) +// transactions from information_schema.innodb_trx. It has no opinion on +// how long is "too long" — that judgment belongs to +// domain.DetectPreparedTransactionWarnings, unchanged from the Postgres +// adapter. +type PreparedTransactionCollector struct { + db *sql.DB +} + +func NewPreparedTransactionCollector(db *sql.DB) *PreparedTransactionCollector { + return &PreparedTransactionCollector{db: db} +} + +func (c *PreparedTransactionCollector) Fetch(ctx context.Context) ([]domain.PreparedTransactionInfo, error) { + rows, err := c.db.QueryContext(ctx, preparedTransactionQuery) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + transactions := make([]domain.PreparedTransactionInfo, 0) + for rows.Next() { + var trxID uint64 + var tx domain.PreparedTransactionInfo + if err := rows.Scan(&trxID, &tx.Database, &tx.Owner, &tx.AgeSeconds); err != nil { + return nil, err + } + tx.GID = strconv.FormatUint(trxID, 10) + transactions = append(transactions, tx) + } + + return transactions, rows.Err() +} diff --git a/go/internal/infrastructure/mysql/query_text_collector.go b/go/internal/infrastructure/mysql/query_text_collector.go new file mode 100644 index 0000000..67f924c --- /dev/null +++ b/go/internal/infrastructure/mysql/query_text_collector.go @@ -0,0 +1,52 @@ +package mysql + +import ( + "context" + "database/sql" +) + +const queryTextsPerTableLimit = 50 + +// queryTextsForTableQuery mirrors the Postgres adapter's approach: +// find every distinct normalized query shape mentioning the given +// table, ranked by call count so the most common shapes are seen first, +// but never filtered down to only the top N by cost — a rarely-run but +// relevant match must not be silently dropped. +const queryTextsForTableQuery = ` +SELECT DIGEST_TEXT +FROM performance_schema.events_statements_summary_by_digest +WHERE SCHEMA_NAME = DATABASE() + AND DIGEST_TEXT LIKE CONCAT('%', ?, '%') +ORDER BY COUNT_STAR DESC +LIMIT ? +` + +// QueryTextCollector finds every distinct normalized query shape that +// mentions a given table, used to guess which columns a table is most +// often filtered on. +type QueryTextCollector struct { + db *sql.DB +} + +func NewQueryTextCollector(db *sql.DB) *QueryTextCollector { + return &QueryTextCollector{db: db} +} + +func (c *QueryTextCollector) FetchForTable(ctx context.Context, table string) ([]string, error) { + rows, err := c.db.QueryContext(ctx, queryTextsForTableQuery, table, queryTextsPerTableLimit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + texts := make([]string, 0) + for rows.Next() { + var text string + if err := rows.Scan(&text); err != nil { + return nil, err + } + texts = append(texts, text) + } + + return texts, rows.Err() +} diff --git a/go/internal/infrastructure/mysql/session_collector.go b/go/internal/infrastructure/mysql/session_collector.go new file mode 100644 index 0000000..de8ac7f --- /dev/null +++ b/go/internal/infrastructure/mysql/session_collector.go @@ -0,0 +1,232 @@ +package mysql + +import ( + "context" + "database/sql" + "strconv" + "time" + + "github.com/fayupable/pgscope/internal/domain" +) + +// activeThreadsQuery reads every connection's current activity from +// performance_schema.threads — MySQL's equivalent of pg_stat_activity. +// Query text comes from events_statements_current.DIGEST_TEXT (normalized, +// literal values replaced with ?), never from threads.PROCESSLIST_INFO, +// which carries the raw, unmasked SQL — same reasoning as every other +// collector that reads query text in this package. +// +// performance_schema.threads also lists MySQL's own background threads +// (event_scheduler, compress_gtid_table, replication I/O/SQL threads, ...) +// with PROCESSLIST_COMMAND = 'Daemon' or similar — not real client +// sessions, and never something a user would want listed as one. +// PROCESSLIST_COMMAND is restricted to ('Query', 'Sleep') to exclude them, +// verified empirically against a live server (see session log for +// 2026-08-29): two Daemon threads slipped through an earlier, looser +// "!= 'Sleep'" filter before this fix. +// +// Unlike Postgres, MySQL has no single "idle in transaction" state on this +// view: a real client connection's PROCESSLIST_COMMAND only ever says +// 'Query' or 'Sleep', regardless of whether it's sitting inside an open, +// uncommitted transaction. innodb_trx is joined in separately (same source +// idle_in_transaction_collector.go already reads) so sessionState can tell +// the two apart. +const activeThreadsQuery = ` +SELECT + th.PROCESSLIST_ID, + COALESCE(th.PROCESSLIST_USER, ''), + COALESCE(th.PROCESSLIST_DB, ''), + COALESCE(th.PROCESSLIST_HOST, ''), + th.PROCESSLIST_COMMAND, + COALESCE(esc.DIGEST_TEXT, '[query not tracked]'), + COALESCE(th.PROCESSLIST_TIME, 0), + (t.trx_id IS NOT NULL) AS has_open_trx +FROM performance_schema.threads th +LEFT JOIN performance_schema.events_statements_current esc ON esc.THREAD_ID = th.THREAD_ID +LEFT JOIN information_schema.innodb_trx t ON t.trx_mysql_thread_id = th.PROCESSLIST_ID +WHERE th.PROCESSLIST_ID IS NOT NULL + AND th.PROCESSLIST_ID != CONNECTION_ID() + AND th.PROCESSLIST_COMMAND IN ('Query', 'Sleep') + AND (th.PROCESSLIST_COMMAND != 'Sleep' OR t.trx_id IS NOT NULL) +` + +// blockingPidsQuery reads just the waiting-pid/blocking-pid relationship +// from sys.innodb_lock_waits — the same view LockWaitCollector reads, but +// without its query-text joins, since BlockedBy only needs PIDs. A waiting +// session can appear more than once here if it's waiting on locks held by +// more than one blocker. +const blockingPidsQuery = `SELECT waiting_pid, blocking_pid FROM sys.innodb_lock_waits` + +// sessionLocksQuery reads every lock a session currently holds or is +// waiting on, from performance_schema.data_locks — MySQL's equivalent of +// pg_locks. Joined against threads to translate its internal THREAD_ID +// into the PROCESSLIST_ID (pid) domain.Session is keyed by. +const sessionLocksQuery = ` +SELECT + th.PROCESSLIST_ID, + dl.LOCK_TYPE, + dl.LOCK_MODE, + COALESCE(dl.OBJECT_NAME, ''), + (dl.LOCK_STATUS = 'GRANTED') +FROM performance_schema.data_locks dl +JOIN performance_schema.threads th ON th.THREAD_ID = dl.THREAD_ID +WHERE th.PROCESSLIST_ID IS NOT NULL +` + +// SessionCollector reads active connections from performance_schema, plus +// (via sys.innodb_lock_waits) which sessions are currently blocked waiting +// for a lock another session holds, and (via performance_schema.data_locks) +// every lock each session holds or is waiting on — the same three pieces +// the Postgres adapter's Collector assembles from pg_stat_activity, +// pg_blocking_pids(), and pg_locks. +type SessionCollector struct { + db *sql.DB +} + +func NewSessionCollector(db *sql.DB) *SessionCollector { + return &SessionCollector{db: db} +} + +func (c *SessionCollector) FetchActiveSessions(ctx context.Context) ([]domain.Session, error) { + rows, err := c.db.QueryContext(ctx, activeThreadsQuery) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + sessions := make([]domain.Session, 0) + for rows.Next() { + session, err := scanSession(rows) + if err != nil { + return nil, err + } + sessions = append(sessions, session) + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(sessions) == 0 { + return sessions, nil + } + + blockedBy, err := c.fetchBlockingPIDs(ctx) + if err != nil { + return nil, err + } + locksByPID, err := c.fetchLocksByPID(ctx) + if err != nil { + return nil, err + } + for i := range sessions { + // blockedBy[id] is nil (not an empty slice) for any session absent + // from the map — scanSession already defaults BlockedBy to an + // empty, non-nil slice, so only overwrite it when a real match + // exists, keeping JSON output "[]" rather than "null" either way. + if pids, found := blockedBy[sessions[i].ID]; found { + sessions[i].BlockedBy = pids + } + if locks, found := locksByPID[sessions[i].ID]; found { + sessions[i].Locks = locks + } + } + + return sessions, nil +} + +func (c *SessionCollector) fetchLocksByPID(ctx context.Context) (map[string][]domain.LockedObject, error) { + rows, err := c.db.QueryContext(ctx, sessionLocksQuery) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + locksByPID := make(map[string][]domain.LockedObject) + for rows.Next() { + var pid int64 + var lockType, lockMode, resource string + var granted bool + if err := rows.Scan(&pid, &lockType, &lockMode, &resource, &granted); err != nil { + return nil, err + } + + id := strconv.FormatInt(pid, 10) + locksByPID[id] = append(locksByPID[id], domain.LockedObject{ + NativeMode: lockMode, + Severity: classifyLockSeverity(lockType, lockMode), + Resource: resource, + Granted: granted, + }) + } + + return locksByPID, rows.Err() +} + +func (c *SessionCollector) fetchBlockingPIDs(ctx context.Context) (map[string][]string, error) { + rows, err := c.db.QueryContext(ctx, blockingPidsQuery) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + blockedBy := make(map[string][]string) + for rows.Next() { + var waitingPID, blockingPID int64 + if err := rows.Scan(&waitingPID, &blockingPID); err != nil { + return nil, err + } + waiting := strconv.FormatInt(waitingPID, 10) + blockedBy[waiting] = append(blockedBy[waiting], strconv.FormatInt(blockingPID, 10)) + } + + return blockedBy, rows.Err() +} + +func scanSession(rows *sql.Rows) (domain.Session, error) { + var ( + pid int64 + user string + db string + host string + command string + query string + processlistTime int64 + hasOpenTxn bool + ) + + if err := rows.Scan(&pid, &user, &db, &host, &command, &query, &processlistTime, &hasOpenTxn); err != nil { + return domain.Session{}, err + } + + queryStarted := time.Now().Add(-time.Duration(processlistTime) * time.Second) + + return domain.Session{ + ID: strconv.FormatInt(pid, 10), + User: user, + ApplicationName: db, + ClientAddress: host, + State: sessionState(command, hasOpenTxn), + WaitEventType: domain.WaitEventTypeNone, + Query: query, + Operation: domain.ClassifyOperation(query), + QueryStarted: queryStarted, + Duration: time.Duration(processlistTime) * time.Second, + BlockedBy: make([]string, 0), + Locks: make([]domain.LockedObject, 0), + }, nil +} + +// sessionState maps MySQL's coarse PROCESSLIST_COMMAND ('Query' or +// 'Sleep') plus whether an InnoDB transaction is currently open into +// domain.SessionState's three-way distinction. A 'Sleep' connection with +// no open transaction is genuinely idle and excluded by activeThreadsQuery +// before this ever runs — every row reaching here is either running a +// query or idling inside an open transaction. +func sessionState(command string, hasOpenTxn bool) domain.SessionState { + if command == "Query" { + return domain.SessionStateActive + } + if hasOpenTxn { + return domain.SessionStateIdleInTransaction + } + return domain.SessionStateIdle +} diff --git a/go/internal/infrastructure/mysql/top_query_collector.go b/go/internal/infrastructure/mysql/top_query_collector.go new file mode 100644 index 0000000..71f2818 --- /dev/null +++ b/go/internal/infrastructure/mysql/top_query_collector.go @@ -0,0 +1,62 @@ +package mysql + +import ( + "context" + "database/sql" + + "github.com/fayupable/pgscope/internal/domain" +) + +const topQueriesLimit = 100 + +// topQueriesQuery reads the most expensive normalized query shapes from +// performance_schema.events_statements_summary_by_digest — MySQL's +// equivalent of pg_stat_statements. DIGEST_TEXT is already normalized +// (literal values replaced with ?), same privacy property as Postgres's +// pg_stat_statements query column. SUM_TIMER_WAIT/AVG_TIMER_WAIT are in +// picoseconds, converted to milliseconds (÷ 1e9) to match +// domain.SlowQuery's unit, which the Postgres adapter already reports in +// milliseconds. +const topQueriesQuery = ` +SELECT + DIGEST_TEXT, + COUNT_STAR, + SUM_TIMER_WAIT / 1000000000, + AVG_TIMER_WAIT / 1000000000 +FROM performance_schema.events_statements_summary_by_digest +WHERE SCHEMA_NAME = DATABASE() + AND DIGEST_TEXT IS NOT NULL +ORDER BY SUM_TIMER_WAIT DESC +LIMIT ? +` + +// TopQueryCollector reads the most expensive normalized query shapes from +// performance_schema. Its only job is fetching that one thing — advisory +// judgments live in their own dedicated collectors, same split as the +// Postgres adapter. +type TopQueryCollector struct { + db *sql.DB +} + +func NewTopQueryCollector(db *sql.DB) *TopQueryCollector { + return &TopQueryCollector{db: db} +} + +func (c *TopQueryCollector) Fetch(ctx context.Context) ([]domain.SlowQuery, error) { + rows, err := c.db.QueryContext(ctx, topQueriesQuery, topQueriesLimit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + queries := make([]domain.SlowQuery, 0) + for rows.Next() { + var q domain.SlowQuery + if err := rows.Scan(&q.Query, &q.Calls, &q.TotalExecMs, &q.MeanExecMs); err != nil { + return nil, err + } + queries = append(queries, q) + } + + return queries, rows.Err() +} diff --git a/go/internal/infrastructure/mysql/unlogged_table_collector.go b/go/internal/infrastructure/mysql/unlogged_table_collector.go new file mode 100644 index 0000000..3f7cb53 --- /dev/null +++ b/go/internal/infrastructure/mysql/unlogged_table_collector.go @@ -0,0 +1,51 @@ +package mysql + +import ( + "context" + "database/sql" + + "github.com/fayupable/pgscope/internal/domain" +) + +// memoryTablesQuery reads tables using the MEMORY storage engine — MySQL +// has no literal "UNLOGGED" table type, but MEMORY tables share the exact +// same risk Postgres's UNLOGGED tables carry: all data is lost on a +// restart or crash, since nothing is ever written to disk. +const memoryTablesQuery = ` +SELECT TABLE_NAME +FROM information_schema.TABLES +WHERE TABLE_SCHEMA = DATABASE() + AND ENGINE = 'MEMORY' +` + +// UnloggedTableCollector reads tables the catalog marks as using the +// MEMORY engine. No further judgment is needed here — see +// domain.NewMemoryEngineTable, which carries the same underlying finding +// as the Postgres adapter's domain.NewUnloggedTable but with wording +// specific to MySQL's MEMORY engine. +type UnloggedTableCollector struct { + db *sql.DB +} + +func NewUnloggedTableCollector(db *sql.DB) *UnloggedTableCollector { + return &UnloggedTableCollector{db: db} +} + +func (c *UnloggedTableCollector) Fetch(ctx context.Context) ([]domain.UnloggedTable, error) { + rows, err := c.db.QueryContext(ctx, memoryTablesQuery) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + result := make([]domain.UnloggedTable, 0) + for rows.Next() { + var table string + if err := rows.Scan(&table); err != nil { + return nil, err + } + result = append(result, domain.NewMemoryEngineTable(table)) + } + + return result, rows.Err() +} diff --git a/go/internal/infrastructure/mysql/unused_index_collector.go b/go/internal/infrastructure/mysql/unused_index_collector.go new file mode 100644 index 0000000..64d00aa --- /dev/null +++ b/go/internal/infrastructure/mysql/unused_index_collector.go @@ -0,0 +1,49 @@ +package mysql + +import ( + "context" + "database/sql" + + "github.com/fayupable/pgscope/internal/domain" +) + +// unusedIndexQuery reads MySQL's own unused-index analysis from +// sys.schema_unused_indexes — like the duplicate-index adapter, no +// threshold logic needs to happen here, MySQL's sys schema already +// determined which indexes have had zero reads. Requires SELECT granted +// on the sys schema (see go/README.md's MySQL setup instructions). +const unusedIndexQuery = ` +SELECT object_name, index_name +FROM sys.schema_unused_indexes +WHERE object_schema = DATABASE() +` + +// UnusedIndexCollector reads MySQL's precomputed unused-index findings +// from the sys schema. No further judgment is needed here — see +// domain.NewUnusedIndex, which only formats the explanation text. +type UnusedIndexCollector struct { + db *sql.DB +} + +func NewUnusedIndexCollector(db *sql.DB) *UnusedIndexCollector { + return &UnusedIndexCollector{db: db} +} + +func (c *UnusedIndexCollector) Fetch(ctx context.Context) ([]domain.UnusedIndex, error) { + rows, err := c.db.QueryContext(ctx, unusedIndexQuery) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + result := make([]domain.UnusedIndex, 0) + for rows.Next() { + var table, index string + if err := rows.Scan(&table, &index); err != nil { + return nil, err + } + result = append(result, domain.NewUnusedIndex(table, index)) + } + + return result, rows.Err() +} diff --git a/go/internal/presentation/http/connection_handlers.go b/go/internal/presentation/http/connection_handlers.go new file mode 100644 index 0000000..21c4c6d --- /dev/null +++ b/go/internal/presentation/http/connection_handlers.go @@ -0,0 +1,20 @@ +package http + +import ( + "net/http" + + "github.com/fayupable/pgscope/internal/infrastructure/config" +) + +// handleConnection reports which database engine this server is currently +// configured against. "id" is always "default" for now — there's only ever +// one connection — but it's included from the start so the frontend can +// key its engine-awareness off a connection id rather than assuming a +// single global engine, and this same shape will describe one entry in a +// future GET /api/v1/connections list once multi-connection support lands, +// with no breaking change to this response. +func handleConnection(engine config.DBEngine) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, map[string]any{"id": "default", "engine": engine}) + } +} diff --git a/go/internal/presentation/http/router.go b/go/internal/presentation/http/router.go index bb5bb3f..daf4f3e 100644 --- a/go/internal/presentation/http/router.go +++ b/go/internal/presentation/http/router.go @@ -26,15 +26,26 @@ func NewRouter(broadcaster *sse.Broadcaster, poller *service.Poller, insightsSer mux.Handle("POST /api/v1/auth/login", withRateLimit(loginLimiter, handleLogin(cfg.APIKey, bans, loginAttempts))) mux.HandleFunc("POST /api/v1/auth/logout", handleLogout) mux.Handle("GET /api/v1/auth/status", withAuth(cfg.APIKey, http.HandlerFunc(handleAuthStatus))) - - mux.Handle("GET /api/v1/sessions/stream", withAuth(cfg.APIKey, broadcaster)) - - mux.Handle("POST /api/v1/monitor/start", withAuth(cfg.APIKey, withRateLimit(generalLimiter, handleMonitorStart(poller)))) - mux.Handle("POST /api/v1/monitor/stop", withAuth(cfg.APIKey, withRateLimit(generalLimiter, handleMonitorStop(poller)))) - - mux.Handle("GET /api/v1/history", withAuth(cfg.APIKey, withRateLimit(generalLimiter, handleHistory(poller)))) - - mux.Handle("GET /api/v1/insights", withAuth(cfg.APIKey, withRateLimit(insightsLimiter, withRequestTimeout(insightsTimeout, handleInsights(insightsService))))) + mux.Handle("GET /api/v1/connection", withAuth(cfg.APIKey, handleConnection(cfg.Engine))) + + // broadcaster and poller are nil only when a wiring path (see main.go) + // deliberately doesn't build them — every engine currently in main.go + // builds both, but this guard is what lets a future engine ship with + // just insights before its ISessionCollectorPort adapter exists, + // exactly how MySQL itself shipped in an earlier phase of this project. + if broadcaster != nil { + mux.Handle("GET /api/v1/sessions/stream", withAuth(cfg.APIKey, broadcaster)) + } + + if poller != nil { + mux.Handle("POST /api/v1/monitor/start", withAuth(cfg.APIKey, withRateLimit(generalLimiter, handleMonitorStart(poller)))) + mux.Handle("POST /api/v1/monitor/stop", withAuth(cfg.APIKey, withRateLimit(generalLimiter, handleMonitorStop(poller)))) + mux.Handle("GET /api/v1/history", withAuth(cfg.APIKey, withRateLimit(generalLimiter, handleHistory(poller)))) + } + + if insightsService != nil { + mux.Handle("GET /api/v1/insights", withAuth(cfg.APIKey, withRateLimit(insightsLimiter, withRequestTimeout(insightsTimeout, handleInsights(insightsService))))) + } mux.HandleFunc("/", handleScanProbe(bans, scanAttempts)) diff --git a/web/src/App.tsx b/web/src/App.tsx index 0633e69..e74bf63 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { checkAuthStatus, logout } from './shared/api/authClient' import { LoginScreen } from './features/auth/components/LoginScreen' import { MonitoringStreamProvider } from './shared/api/MonitoringStreamProvider' +import { EngineProvider } from './shared/api/EngineProvider' import { Dashboard } from './features/dashboard/components/Dashboard' import { useTheme } from './shared/hooks/useTheme' @@ -29,9 +30,11 @@ function App() { } return ( - - - + + + + + ) } diff --git a/web/src/features/insights/components/CheckpointHealthCard.tsx b/web/src/features/insights/components/CheckpointHealthCard.tsx index 2080296..591004e 100644 --- a/web/src/features/insights/components/CheckpointHealthCard.tsx +++ b/web/src/features/insights/components/CheckpointHealthCard.tsx @@ -1,16 +1,16 @@ import type { CheckpointHealth } from '../../../shared/types/insights' +import { HealthCardHeader } from './HealthCardHeader' export function CheckpointHealthCard({ health }: { health: CheckpointHealth }) { const isWarning = Boolean(health.warning) return (
-
- - {isWarning ? '⚠' : '✓'} - - Checkpoints -
+

{health.requestedCheckpoints} / {health.scheduledCheckpoints + health.requestedCheckpoints} forced ({health.requestedRatio.toFixed(1)}%) diff --git a/web/src/features/insights/components/ConnectionSaturationCard.tsx b/web/src/features/insights/components/ConnectionSaturationCard.tsx index 33c0012..bbc6817 100644 --- a/web/src/features/insights/components/ConnectionSaturationCard.tsx +++ b/web/src/features/insights/components/ConnectionSaturationCard.tsx @@ -1,16 +1,16 @@ import type { ConnectionSaturation } from '../../../shared/types/insights' +import { HealthCardHeader } from './HealthCardHeader' export function ConnectionSaturationCard({ saturation }: { saturation: ConnectionSaturation }) { const isWarning = Boolean(saturation.warning) return (

-
- - {isWarning ? '⚠' : '✓'} - - Connections -
+

{saturation.activeConnections} / {saturation.maxConnections} ({saturation.usagePercent.toFixed(0)}%) diff --git a/web/src/features/insights/components/DatabaseSizeCard.tsx b/web/src/features/insights/components/DatabaseSizeCard.tsx index 84adbed..93e5764 100644 --- a/web/src/features/insights/components/DatabaseSizeCard.tsx +++ b/web/src/features/insights/components/DatabaseSizeCard.tsx @@ -1,12 +1,10 @@ import { formatBytes } from '../utils/formatBytes' +import { HealthCardHeader } from './HealthCardHeader' export function DatabaseSizeCard({ totalBytes }: { totalBytes: number }) { return (

-
- - Database size -
+

{formatBytes(totalBytes)}

) diff --git a/web/src/features/insights/components/HealthCardHeader.tsx b/web/src/features/insights/components/HealthCardHeader.tsx new file mode 100644 index 0000000..b2799a7 --- /dev/null +++ b/web/src/features/insights/components/HealthCardHeader.tsx @@ -0,0 +1,21 @@ +// Shared header for every health-card: status icon + title were previously +// copy-pasted identically into all nine card components. Pulling it out +// here means the always-visible plain-language `subtitle` — what this card +// actually checks, independent of whether it currently has anything to +// report — only has to be written once per card's data, not once per +// card's markup. +export function HealthCardHeader({ title, subtitle, ok }: { title: string; subtitle: string; ok: boolean }) { + return ( +
+
+
+ + {ok ? '✓' : '⚠'} + + {title} +
+

{subtitle}

+
+
+ ) +} diff --git a/web/src/features/insights/components/HealthPanel.tsx b/web/src/features/insights/components/HealthPanel.tsx index ff69fae..f402670 100644 --- a/web/src/features/insights/components/HealthPanel.tsx +++ b/web/src/features/insights/components/HealthPanel.tsx @@ -4,6 +4,7 @@ import type { DatabaseSizeInfo, IdleInTransactionWarning, InvalidIndex, + LockWaitWarning, LongRunningQueryWarning, PhysicalIOHotspot, PreparedTransactionWarning, @@ -28,6 +29,8 @@ import { PreparedTransactionsCard } from './PreparedTransactionsCard' import { ReplicationSlotsCard } from './ReplicationSlotsCard' import { LongRunningQueriesCard } from './LongRunningQueriesCard' import { UnloggedTablesCard } from './UnloggedTablesCard' +import { LockWaitCard } from './LockWaitCard' +import { useEngine } from '../../../shared/api/engineContext' export function HealthPanel({ databaseSize, @@ -45,6 +48,7 @@ export function HealthPanel({ replicationSlotWarnings, longRunningQueryWarnings, unloggedTables, + lockWaitWarnings, }: { databaseSize: DatabaseSizeInfo connectionSaturation: ConnectionSaturation @@ -61,32 +65,54 @@ export function HealthPanel({ replicationSlotWarnings: ReplicationSlotWarning[] longRunningQueryWarnings: LongRunningQueryWarning[] unloggedTables: UnloggedTable[] + lockWaitWarnings: LockWaitWarning[] }) { + // Postgres-specific concepts (VACUUM, WAL checkpoints, replication + // slots, pg_stat_kcache-based physical I/O) have no MySQL equivalent — + // showing them against a MySQL connection would be actively misleading + // (e.g. telling a MySQL user to "restart PostgreSQL"). `engine` is + // null only during the brief moment the connection info hasn't loaded + // yet; defaulting to "show" in that window avoids a flash of missing + // content for the common (Postgres) case. + const engine = useEngine() + const isMysql = engine === 'mysql' + return (
- - + {!isMysql && ( + + )} + {!isMysql && } - + {!isMysql && } + {isMysql && }
-
Vacuum health
- + {!isMysql && ( + <> +
Vacuum health
+ + + )}
Idle in transaction
-
Replication lag
- + {!isMysql && ( + <> +
Replication lag
+ -
Physical I/O hotspots
- +
Physical I/O hotspots
+ + + )}
Largest tables
diff --git a/web/src/features/insights/components/InsightsPanel.css b/web/src/features/insights/components/InsightsPanel.css index fcd4357..ae71ac9 100644 --- a/web/src/features/insights/components/InsightsPanel.css +++ b/web/src/features/insights/components/InsightsPanel.css @@ -221,6 +221,17 @@ gap: var(--space-2); } +/* Compact health-card grid only — a card with many warnings (e.g. dozens + of lock waits) would otherwise grow tall enough to distort the whole + grid row's height. Full-width tab views (DuplicateIndexesTable and + friends, which reuse this same list class) already have the whole page + to scroll in, so they're deliberately left unbounded here. */ +.health-card .index-candidates-table__rationales { + max-height: 180px; + overflow-y: auto; + padding-right: var(--space-2); +} + .index-candidates-table__rationales li { font-size: 12px; color: var(--text-secondary); @@ -331,10 +342,13 @@ } .health-card__header { + margin-bottom: var(--space-2); +} + +.health-card__title-row { display: flex; align-items: center; gap: var(--space-2); - margin-bottom: var(--space-2); } .health-card__status { @@ -357,6 +371,16 @@ color: var(--text-muted); } +/* Always visible, regardless of whether the card currently has anything to + report — a beginner shouldn't need a warning to appear before learning + what a card even checks. */ +.health-card__subtitle { + margin: 2px 0 0; + font-size: 11px; + line-height: 1.4; + color: var(--text-muted); +} + .health-card__value { font-size: 20px; font-weight: 700; diff --git a/web/src/features/insights/components/InsightsPanel.tsx b/web/src/features/insights/components/InsightsPanel.tsx index f85a365..0b7509e 100644 --- a/web/src/features/insights/components/InsightsPanel.tsx +++ b/web/src/features/insights/components/InsightsPanel.tsx @@ -7,6 +7,7 @@ import { UnusedIndexesTable } from './UnusedIndexesTable' import { FunctionCostsTable } from './FunctionCostsTable' import { PaginationWarningsTable } from './PaginationWarningsTable' import { HealthPanel } from './HealthPanel' +import { useEngine } from '../../../shared/api/engineContext' import './InsightsPanel.css' type Tab = 'queries' | 'candidates' | 'duplicates' | 'unused' | 'functions' | 'pagination' | 'health' @@ -14,6 +15,11 @@ type Tab = 'queries' | 'candidates' | 'duplicates' | 'unused' | 'functions' | 'p export function InsightsPanel() { const { insights, loading, error, refresh } = useInsights() const [tab, setTab] = useState('queries') + // MySQL has no function/trigger cost tracking at all (see + // mysql.InsightsCollector on the backend) — the Postgres-only + // "Functions & Triggers" tab would otherwise show instructions to + // reload PostgreSQL even against a MySQL connection. + const isMysql = useEngine() === 'mysql' return (
@@ -31,9 +37,11 @@ export function InsightsPanel() { - + {!isMysql && ( + + )} @@ -73,7 +81,7 @@ export function InsightsPanel() { )} - {tab === 'functions' && ( + {tab === 'functions' && !isMysql && (
)} diff --git a/web/src/features/insights/components/InvalidObjectsCard.tsx b/web/src/features/insights/components/InvalidObjectsCard.tsx index 0f3202f..a01ceed 100644 --- a/web/src/features/insights/components/InvalidObjectsCard.tsx +++ b/web/src/features/insights/components/InvalidObjectsCard.tsx @@ -1,4 +1,5 @@ import type { InvalidIndex, UnvalidatedConstraint } from '../../../shared/types/insights' +import { HealthCardHeader } from './HealthCardHeader' export function InvalidObjectsCard({ invalidIndexes, @@ -11,12 +12,11 @@ export function InvalidObjectsCard({ return (
-
- - {hasIssues ? '⚠' : '✓'} - - Invalid indexes & constraints -
+ {!hasIssues ? (

No invalid indexes or unvalidated constraints found.

diff --git a/web/src/features/insights/components/LockWaitCard.tsx b/web/src/features/insights/components/LockWaitCard.tsx new file mode 100644 index 0000000..e1ac508 --- /dev/null +++ b/web/src/features/insights/components/LockWaitCard.tsx @@ -0,0 +1,31 @@ +import type { LockWaitWarning } from '../../../shared/types/insights' +import { HealthCardHeader } from './HealthCardHeader' + +export function LockWaitCard({ warnings }: { warnings: LockWaitWarning[] }) { + const hasIssues = warnings.length > 0 + + return ( +
+ + + {!hasIssues ? ( +

No sessions waiting on a lock for too long.

+ ) : ( +
    + {warnings.map((w) => ( +
  • + + PID {w.waitingPid} blocked by PID {w.blockingPid} ({w.waitAgeSeconds.toFixed(0)}s): + {' '} + {w.explanation} +
  • + ))} +
+ )} +
+ ) +} diff --git a/web/src/features/insights/components/LongRunningQueriesCard.tsx b/web/src/features/insights/components/LongRunningQueriesCard.tsx index 49c94eb..d2997ec 100644 --- a/web/src/features/insights/components/LongRunningQueriesCard.tsx +++ b/web/src/features/insights/components/LongRunningQueriesCard.tsx @@ -1,16 +1,16 @@ import type { LongRunningQueryWarning } from '../../../shared/types/insights' +import { HealthCardHeader } from './HealthCardHeader' export function LongRunningQueriesCard({ warnings }: { warnings: LongRunningQueryWarning[] }) { const hasIssues = warnings.length > 0 return (
-
- - {hasIssues ? '⚠' : '✓'} - - Long-running queries -
+ {!hasIssues ? (

No long-running active queries.

diff --git a/web/src/features/insights/components/PaginationWarningsTable.tsx b/web/src/features/insights/components/PaginationWarningsTable.tsx index ada1698..0e961bc 100644 --- a/web/src/features/insights/components/PaginationWarningsTable.tsx +++ b/web/src/features/insights/components/PaginationWarningsTable.tsx @@ -1,4 +1,5 @@ import type { PaginationWarning } from '../../../shared/types/insights' +import { useEngine } from '../../../shared/api/engineContext' export function PaginationWarningsTable({ warnings, @@ -7,6 +8,11 @@ export function PaginationWarningsTable({ warnings: PaginationWarning[] nestedStatementsTracked: boolean }) { + // pg_stat_statements.track is a Postgres-only setting — MySQL has no + // equivalent concept, and always reports nestedStatementsTracked as + // false (unset) rather than true, so this note must not show there. + const isMysql = useEngine() === 'mysql' + return ( <> {warnings.length === 0 ? ( @@ -23,7 +29,7 @@ export function PaginationWarningsTable({ )} - {!nestedStatementsTracked && ( + {!isMysql && !nestedStatementsTracked && (

Note: pg_stat_statements.track is currently top — queries run inside functions, procedures, or DO blocks aren't tracked individually, so this list diff --git a/web/src/features/insights/components/PreparedTransactionsCard.tsx b/web/src/features/insights/components/PreparedTransactionsCard.tsx index e9768ce..9cdded3 100644 --- a/web/src/features/insights/components/PreparedTransactionsCard.tsx +++ b/web/src/features/insights/components/PreparedTransactionsCard.tsx @@ -1,16 +1,16 @@ import type { PreparedTransactionWarning } from '../../../shared/types/insights' +import { HealthCardHeader } from './HealthCardHeader' export function PreparedTransactionsCard({ warnings }: { warnings: PreparedTransactionWarning[] }) { const hasIssues = warnings.length > 0 return (

-
- - {hasIssues ? '⚠' : '✓'} - - Prepared transactions -
+ {!hasIssues ? (

No orphaned prepared transactions.

diff --git a/web/src/features/insights/components/ReplicationSlotsCard.tsx b/web/src/features/insights/components/ReplicationSlotsCard.tsx index d4ad412..44269a0 100644 --- a/web/src/features/insights/components/ReplicationSlotsCard.tsx +++ b/web/src/features/insights/components/ReplicationSlotsCard.tsx @@ -1,16 +1,16 @@ import type { ReplicationSlotWarning } from '../../../shared/types/insights' +import { HealthCardHeader } from './HealthCardHeader' export function ReplicationSlotsCard({ warnings }: { warnings: ReplicationSlotWarning[] }) { const hasIssues = warnings.length > 0 return (
-
- - {hasIssues ? '⚠' : '✓'} - - Replication slots -
+ {!hasIssues ? (

No replication slots retaining excessive WAL.

diff --git a/web/src/features/insights/components/SequenceOverflowTable.tsx b/web/src/features/insights/components/SequenceOverflowTable.tsx index f4a67eb..e559642 100644 --- a/web/src/features/insights/components/SequenceOverflowTable.tsx +++ b/web/src/features/insights/components/SequenceOverflowTable.tsx @@ -1,13 +1,13 @@ import type { SequenceOverflowRisk } from '../../../shared/types/insights' +import { HealthCardHeader } from './HealthCardHeader' + +const SUBTITLE = 'Auto-incrementing IDs getting close to their maximum value.' export function SequenceOverflowTable({ risks }: { risks: SequenceOverflowRisk[] }) { if (risks.length === 0) { return (
-
- - Sequence overflow risk -
+

No sequences approaching their maximum value.

) @@ -15,10 +15,7 @@ export function SequenceOverflowTable({ risks }: { risks: SequenceOverflowRisk[] return (
-
- - Sequence overflow risk -
+
    {risks.map((r, i) => (
  • diff --git a/web/src/features/insights/components/UnloggedTablesCard.tsx b/web/src/features/insights/components/UnloggedTablesCard.tsx index 85cb6e2..0ab4d21 100644 --- a/web/src/features/insights/components/UnloggedTablesCard.tsx +++ b/web/src/features/insights/components/UnloggedTablesCard.tsx @@ -1,16 +1,16 @@ import type { UnloggedTable } from '../../../shared/types/insights' +import { HealthCardHeader } from './HealthCardHeader' export function UnloggedTablesCard({ tables }: { tables: UnloggedTable[] }) { const hasIssues = tables.length > 0 return (
    -
    - - {hasIssues ? '⚠' : '✓'} - - Unlogged tables -
    + {!hasIssues ? (

    No unlogged tables found.

    diff --git a/web/src/shared/api/EngineProvider.tsx b/web/src/shared/api/EngineProvider.tsx new file mode 100644 index 0000000..426714d --- /dev/null +++ b/web/src/shared/api/EngineProvider.tsx @@ -0,0 +1,27 @@ +import { useEffect, useState, type ReactNode } from 'react' +import { getConnection } from './connectionClient' +import { EngineContext } from './engineContext' +import type { Engine } from '../types/connection' + +/** + * Fetches which database engine this server is connected to, once, and + * makes it available to the whole app via context. The engine never + * changes mid-session (it's fixed by how the backend was started), so a + * one-time fetch is enough — no polling, no SSE. + */ +export function EngineProvider({ children }: { children: ReactNode }) { + const [engine, setEngine] = useState(null) + + useEffect(() => { + getConnection() + .then((connection) => setEngine(connection.engine)) + .catch(() => { + // Leave engine as null on failure — consumers already treat + // null as "unknown," which is the honest state here; no + // separate error UI is worth building for a field this + // low-stakes (only affects which advisory cards render). + }) + }, []) + + return {children} +} diff --git a/web/src/shared/api/connectionClient.ts b/web/src/shared/api/connectionClient.ts new file mode 100644 index 0000000..567bbf2 --- /dev/null +++ b/web/src/shared/api/connectionClient.ts @@ -0,0 +1,9 @@ +import type { Connection } from '../types/connection' + +export async function getConnection(): Promise { + const response = await fetch('/api/v1/connection', { credentials: 'include' }) + if (!response.ok) { + throw new Error(`Failed to fetch connection: ${response.status}`) + } + return response.json() +} diff --git a/web/src/shared/api/engineContext.ts b/web/src/shared/api/engineContext.ts new file mode 100644 index 0000000..3820245 --- /dev/null +++ b/web/src/shared/api/engineContext.ts @@ -0,0 +1,11 @@ +import { createContext, useContext } from 'react' +import type { Engine } from '../types/connection' + +// null means "not known yet" — the initial fetch hasn't resolved. Consumers +// that need to hide/show engine-specific UI should treat null the same as +// "don't know, render nothing engine-specific yet" rather than guessing. +export const EngineContext = createContext(null) + +export function useEngine(): Engine | null { + return useContext(EngineContext) +} diff --git a/web/src/shared/api/insightsClient.ts b/web/src/shared/api/insightsClient.ts index 4576e31..66187f6 100644 --- a/web/src/shared/api/insightsClient.ts +++ b/web/src/shared/api/insightsClient.ts @@ -1,5 +1,52 @@ import type { Insights } from '../types/insights' +// The wire shape as the Go backend actually serializes it. Every array +// field the MySQL adapter deliberately leaves unpopulated (see +// mysql.InsightsCollector's doc comment on the backend) is a nil Go slice, +// which encodes as JSON `null`, not `[]`. Postgres always populates every +// field, so this only bites when talking to a MySQL-backed server — but +// every consumer of `Insights` should be able to trust every array is a +// real array, never null, regardless of which engine answered. This type +// captures the untrusted wire shape; normalizeInsights below is the one +// place responsible for closing the gap. +type RawInsights = Omit< + Insights, + | 'functionCosts' + | 'invalidIndexes' + | 'unvalidatedConstraints' + | 'vacuumHealthWarnings' + | 'replicationLagWarnings' + | 'physicalIOHotspots' + | 'replicationSlotWarnings' + | 'lockWaitWarnings' +> & { + functionCosts: Insights['functionCosts'] | null + invalidIndexes: Insights['invalidIndexes'] | null + unvalidatedConstraints: Insights['unvalidatedConstraints'] | null + vacuumHealthWarnings: Insights['vacuumHealthWarnings'] | null + replicationLagWarnings: Insights['replicationLagWarnings'] | null + physicalIOHotspots: Insights['physicalIOHotspots'] | null + replicationSlotWarnings: Insights['replicationSlotWarnings'] | null + // Absent entirely (undefined) on a Postgres server, since that engine + // has never heard of this field — as opposed to the fields above, + // which Postgres always sends populated but MySQL may null out. + lockWaitWarnings?: Insights['lockWaitWarnings'] | null +} + +function normalizeInsights(raw: RawInsights): Insights { + return { + ...raw, + functionCosts: raw.functionCosts ?? [], + invalidIndexes: raw.invalidIndexes ?? [], + unvalidatedConstraints: raw.unvalidatedConstraints ?? [], + vacuumHealthWarnings: raw.vacuumHealthWarnings ?? [], + replicationLagWarnings: raw.replicationLagWarnings ?? [], + physicalIOHotspots: raw.physicalIOHotspots ?? [], + replicationSlotWarnings: raw.replicationSlotWarnings ?? [], + lockWaitWarnings: raw.lockWaitWarnings ?? [], + } +} + export async function getInsights(): Promise { const response = await fetch('/api/v1/insights') if (!response.ok) { @@ -8,5 +55,5 @@ export async function getInsights(): Promise { } throw new Error(`Failed to fetch insights: ${response.status}`) } - return response.json() -} \ No newline at end of file + return normalizeInsights(await response.json()) +} diff --git a/web/src/shared/types/connection.ts b/web/src/shared/types/connection.ts new file mode 100644 index 0000000..9e441d2 --- /dev/null +++ b/web/src/shared/types/connection.ts @@ -0,0 +1,6 @@ +export type Engine = 'postgres' | 'mysql' + +export interface Connection { + id: string + engine: Engine +} diff --git a/web/src/shared/types/insights.ts b/web/src/shared/types/insights.ts index 863b5fe..e988202 100644 --- a/web/src/shared/types/insights.ts +++ b/web/src/shared/types/insights.ts @@ -76,6 +76,7 @@ export interface Insights { replicationSlotWarnings: ReplicationSlotWarning[] longRunningQueryWarnings: LongRunningQueryWarning[] unloggedTables: UnloggedTable[] + lockWaitWarnings: LockWaitWarning[] } export interface TableSize { @@ -181,4 +182,16 @@ export interface LongRunningQueryWarning { export interface UnloggedTable { table: string explanation: string +} + +// LockWaitWarning is MySQL-only for now — Postgres responses never include +// this field. See domain.LockWaitSession on the backend for why. +export interface LockWaitWarning { + waitingPid: number + waitingQuery: string + blockingPid: number + blockingQuery: string + lockedTable: string + waitAgeSeconds: number + explanation: string } \ No newline at end of file