Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 73 additions & 8 deletions README.md

Large diffs are not rendered by default.

60 changes: 60 additions & 0 deletions go/cmd/pgscope/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
Expand All @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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() {
Expand Down
2 changes: 2 additions & 0 deletions go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go/go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
20 changes: 20 additions & 0 deletions go/internal/domain/duplicate_index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions go/internal/domain/insights.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
64 changes: 64 additions & 0 deletions go/internal/domain/lock_wait.go
Original file line number Diff line number Diff line change
@@ -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
}
97 changes: 97 additions & 0 deletions go/internal/domain/lock_wait_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
15 changes: 15 additions & 0 deletions go/internal/domain/unlogged_table.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
}
}
17 changes: 17 additions & 0 deletions go/internal/domain/unused_index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading