diff --git a/.gitignore b/.gitignore index 663db3e..52e44c3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,13 @@ go.work go.work.sum + +# SQLite history store (local data, contains redacted query text) +go/data/ +*.db +*.db-wal +*.db-shm + # Node / Vite (web/) web/node_modules/ web/dist/ diff --git a/SECURITY.md b/SECURITY.md index 7c0256d..8c6387c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,6 +23,12 @@ Please include: - The lack of a full user/role system — this is a deliberate, documented design choice for a single-operator tool (see [go/README.md](go/README.md)'s security model section), not an oversight - Behavior when pgscope is run without a reverse proxy / without HTTPS — the docs are explicit that this is required for the `Secure` session cookie to work correctly, and that responsibility sits with the deployer, not the application +## History store and query redaction + +pgscope persists a rolling window of session snapshots to a local SQLite file (`internal/infrastructure/history/sqlite_store.go`) so past activity can be replayed. Since `pg_stat_activity` reports query text with its actual bound parameter values, every session's query is passed through a best-effort redaction pass (`redactQueryLiterals`) before it's ever written to disk — string and standalone numeric literals are replaced with `***`. + +This redaction is **not a formal guarantee**. It's a character-level scanner, not a SQL parser, and known gaps include dollar-quoted strings (`$$...$$`) and some less common literal syntaxes. Treat the history file with the same sensitivity as the monitored database itself: it lives on the pgscope host's local disk, is not exposed over any network protocol, and should be protected by normal filesystem permissions and host-level access control, same as any other credential-adjacent artifact. + ## Response time This is a single-maintainer open-source project, not a company with an SLA. I'll do my best to acknowledge a report within a few days and follow up with a fix or a clear explanation of why something isn't a vulnerability. diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 599e9b6..efdba15 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -18,6 +18,9 @@ services: PGSCOPE_API_KEY: ${PGSCOPE_API_KEY} PGSCOPE_HTTP_PORT: 8090 PGSCOPE_POLL_INTERVAL_SECONDS: ${PGSCOPE_POLL_INTERVAL_SECONDS:-1} + PGSCOPE_HISTORY_DB_PATH: /data/pgscope.db + volumes: + - pgscope_history_local:/data ports: - "8090:8090" mem_limit: 128m @@ -49,4 +52,7 @@ services: ports: - "8080:80" mem_limit: 32m - mem_reservation: 16m \ No newline at end of file + mem_reservation: 16m + +volumes: + pgscope_history_local: \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 77e64c6..e36d192 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,6 +60,9 @@ services: PGSCOPE_API_KEY: ${PGSCOPE_API_KEY} PGSCOPE_HTTP_PORT: 8090 PGSCOPE_POLL_INTERVAL_SECONDS: ${PGSCOPE_POLL_INTERVAL_SECONDS:-1} + PGSCOPE_HISTORY_DB_PATH: /data/pgscope.db + volumes: + - pgscope_history:/data # LOCAL TEST ONLY: same reasoning as postgres above. On a server, # remove this port mapping, only your own nginx (running outside this # compose file, already installed on the server) should be able to @@ -88,4 +91,5 @@ services: mem_reservation: 64m volumes: - postgres_data: \ No newline at end of file + postgres_data: + pgscope_history: \ No newline at end of file diff --git a/go/Dockerfile b/go/Dockerfile index d01faac..16db0c4 100644 --- a/go/Dockerfile +++ b/go/Dockerfile @@ -14,6 +14,8 @@ COPY . . # nothing beyond what's strictly needed to run the binary. RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /pgscope ./cmd/pgscope +RUN mkdir -p /data + # distroless/static includes CA certificates (needed if PGSCOPE_DATABASE_URL # ever uses sslmode=verify-full against a cert-issuing Postgres) and nothing # else: no shell, no package manager, drastically smaller attack surface @@ -22,6 +24,7 @@ FROM gcr.io/distroless/static-debian12:nonroot AS runtime WORKDIR /app COPY --from=builder /pgscope /app/pgscope +COPY --chown=nonroot:nonroot --from=builder /data /data USER nonroot:nonroot diff --git a/go/cmd/pgscope/main.go b/go/cmd/pgscope/main.go index 81544d4..40acbd0 100644 --- a/go/cmd/pgscope/main.go +++ b/go/cmd/pgscope/main.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "log/slog" "net" "net/http" @@ -43,25 +44,39 @@ func run() error { } defer 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 := buildPoller(pool, broadcaster, cfg) + poller := buildPoller(pool, broadcaster, historyStore, cfg) insightsService := buildInsightsService(pool) server := buildServer(broadcaster, poller, insightsService, cfg) go poller.Run(ctx) + go runHistoryPruner(ctx, historyStore, cfg.HistoryRetention, cfg.HistoryMaxDBSizeBytes) return runServer(ctx, server) } -func buildPoller(pool *pgxpool.Pool, broadcaster *sse.Broadcaster, cfg config.Config) *service.Poller { +func buildPoller(pool *pgxpool.Pool, broadcaster *sse.Broadcaster, historyStore *history.SQLiteStore, cfg config.Config) *service.Poller { collector := postgres.NewCollector(pool) monitoringService := service.NewMonitoringService(collector) dbStatsCollector := postgres.NewDatabaseStatsCollector(pool) publisher := sse.NewSessionPublisher(broadcaster) - historyStore := history.NewRingBufferStore() - return service.NewPoller(monitoringService, dbStatsCollector, publisher, historyStore, cfg.PollInterval) + return service.NewPoller( + monitoringService, + dbStatsCollector, + publisher, + historyStore, + cfg.PollInterval, + cfg.HistoryRecordInterval, + cfg.HistoryMaxSessionsPerSnapshot, + ) } func buildInsightsService(pool *pgxpool.Pool) *service.InsightsService { @@ -99,3 +114,35 @@ func runServer(ctx context.Context, server *http.Server) error { return err } } + +const historyPruneInterval = 6 * time.Hour + +// runHistoryPruner periodically deletes periodic snapshots older than the +// configured retention window, in small batches (see PruneOlderThan) so it +// never causes a noticeable CPU or lock spike. It also caps the number of +// incident snapshots kept (see PruneExcessIncidents), since those are never +// pruned by age, and enforces a hard disk-size backstop (EnforceMaxSize) on +// top of the age-based retention, in case an unusually busy monitored +// database outpaces it. +func runHistoryPruner(ctx context.Context, store *history.SQLiteStore, retention time.Duration, maxDBSizeBytes int64) { + ticker := time.NewTicker(historyPruneInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + cutoff := time.Now().Add(-retention) + if err := store.PruneOlderThan(ctx, cutoff); err != nil { + slog.Error("history prune failed", "error", err) + } + if err := store.PruneExcessIncidents(ctx, history.MaxIncidentRows); err != nil { + slog.Error("incident prune failed", "error", err) + } + if err := store.EnforceMaxSize(ctx, maxDBSizeBytes); err != nil { + slog.Error("history size enforcement failed", "error", err) + } + } + } +} diff --git a/go/go.mod b/go/go.mod index 86f18b5..e681b1e 100644 --- a/go/go.mod +++ b/go/go.mod @@ -5,12 +5,22 @@ go 1.25.0 require ( github.com/jackc/pgx/v5 v5.10.0 golang.org/x/time v0.15.0 + modernc.org/sqlite v1.54.0 ) require ( + github.com/dustin/go-humanize v1.0.1 // 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 github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/sync v0.17.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.29.0 // indirect + modernc.org/libc v1.74.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go/go.sum b/go/go.sum index c8a2278..b0e2a77 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,6 +1,14 @@ 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/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= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -9,20 +17,61 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/go/internal/application/port/output/history_store.go b/go/internal/application/port/output/history_store.go index 97b7254..75a7f22 100644 --- a/go/internal/application/port/output/history_store.go +++ b/go/internal/application/port/output/history_store.go @@ -2,6 +2,7 @@ package output import ( "context" + "time" "github.com/fayupable/pgscope/internal/domain" ) @@ -13,6 +14,10 @@ import ( // stored (in-memory ring buffer, database, ...). type IHistoryStorePort interface { Append(ctx context.Context, snapshot domain.Snapshot) error - Recent(ctx context.Context) ([]domain.Snapshot, error) + // Recent returns snapshots captured at or after since, in chronological + // order. Incident snapshots within the window are always returned in + // full; implementations may downsample periodic snapshots to keep the + // result bounded for wide windows. + Recent(ctx context.Context, since time.Time) ([]domain.Snapshot, error) Incidents(ctx context.Context) ([]domain.Snapshot, error) } diff --git a/go/internal/application/service/poller.go b/go/internal/application/service/poller.go index adbdca2..19c46cf 100644 --- a/go/internal/application/service/poller.go +++ b/go/internal/application/service/poller.go @@ -4,32 +4,33 @@ import ( "context" "fmt" "log/slog" + "sort" "time" "github.com/fayupable/pgscope/internal/application/port/output" "github.com/fayupable/pgscope/internal/domain" ) -var allowedRecordMinutes = map[int]bool{5: true, 10: true, 15: true, 30: true} - // Poller periodically pulls active sessions and database-wide activity -// stats, pushes them out through the publisher port, and optionally records -// history snapshots. Two independent, opt-in controls gate its behavior: -// - Monitor: whether the poller queries the database and publishes live -// updates at all. Off by default. -// - Record: whether, while monitoring is active, ticks are also written -// to history. Off by default, always bounded to a fixed set of -// durations (never indefinite). +// stats, pushes them out through the publisher port, and — whenever +// monitoring is active — records history snapshots. There is no separate +// "start recording" concept: recording follows monitoring automatically, +// at its own slower cadence (recordInterval), so a long-running monitoring +// session builds up history in the background without the caller having to +// manage it. Incident snapshots (a new blocking relationship appearing) +// are always recorded immediately, regardless of that cadence. type Poller struct { - monitoringService *MonitoringService - dbStatsCollector output.IDatabaseStatsCollectorPort - publisher output.IEventPublisherPort - historyStore output.IHistoryStorePort - interval time.Duration + monitoringService *MonitoringService + dbStatsCollector output.IDatabaseStatsCollectorPort + publisher output.IEventPublisherPort + historyStore output.IHistoryStorePort + interval time.Duration + recordInterval time.Duration + maxSessionsPerSnapshot int monitorControl *Recorder - recordControl *Recorder previouslyBlocked map[string]bool + lastRecordedAt time.Time } func NewPoller( @@ -38,16 +39,19 @@ func NewPoller( publisher output.IEventPublisherPort, historyStore output.IHistoryStorePort, interval time.Duration, + recordInterval time.Duration, + maxSessionsPerSnapshot int, ) *Poller { return &Poller{ - monitoringService: monitoringService, - dbStatsCollector: dbStatsCollector, - publisher: publisher, - historyStore: historyStore, - interval: interval, - monitorControl: NewRecorder(), - recordControl: NewRecorder(), - previouslyBlocked: make(map[string]bool), + monitoringService: monitoringService, + dbStatsCollector: dbStatsCollector, + publisher: publisher, + historyStore: historyStore, + interval: interval, + recordInterval: recordInterval, + maxSessionsPerSnapshot: maxSessionsPerSnapshot, + monitorControl: NewRecorder(), + previouslyBlocked: make(map[string]bool), } } @@ -64,32 +68,12 @@ func (p *Poller) StartMonitoring(minutes int) error { func (p *Poller) StopMonitoring() { p.monitorControl.Stop() - p.recordControl.Stop() } func (p *Poller) IsMonitoring() bool { return p.monitorControl.IsActive() } -// StartRecording begins writing ticks to history. Recording is always -// bounded to one of a fixed set of presets — never indefinite — since -// history exists for short-window export/replay, not open-ended growth. -func (p *Poller) StartRecording(minutes int) error { - if !allowedRecordMinutes[minutes] { - return fmt.Errorf("minutes must be one of: 5, 10, 15, 30") - } - p.recordControl.Start(time.Duration(minutes) * time.Minute) - return nil -} - -func (p *Poller) StopRecording() { - p.recordControl.Stop() -} - -func (p *Poller) IsRecording() bool { - return p.recordControl.IsActive() -} - func (p *Poller) Run(ctx context.Context) { ticker := time.NewTicker(p.interval) defer ticker.Stop() @@ -112,9 +96,7 @@ func (p *Poller) tick(ctx context.Context) { sessions, ok := p.fetchSessions(ctx) if ok { p.publishSessions(ctx, sessions) - if p.recordControl.IsActive() { - p.recordSnapshot(ctx, sessions) - } + p.maybeRecordSnapshot(ctx, sessions) } p.publishDatabaseStats(ctx) @@ -148,16 +130,51 @@ func (p *Poller) publishDatabaseStats(ctx context.Context) { } } -func (p *Poller) recordSnapshot(ctx context.Context, sessions []domain.Session) { +// maybeRecordSnapshot classifies the current tick and decides whether it's +// actually written to history. An incident (a new blocking relationship +// appearing) is always recorded immediately — that's exactly the moment +// worth keeping. A periodic tick is only recorded once recordInterval has +// elapsed since the last write, decoupling disk writes from the (much +// faster) live poll interval. +func (p *Poller) maybeRecordSnapshot(ctx context.Context, sessions []domain.Session) { + trigger := p.classifyTrigger(sessions) + + isDue := trigger == domain.SnapshotTriggerIncident || time.Since(p.lastRecordedAt) >= p.recordInterval + if !isDue { + return + } + snapshot := domain.Snapshot{ - Sessions: sessions, + Sessions: trimSessions(sessions, trigger, p.maxSessionsPerSnapshot), CapturedAt: time.Now(), - Trigger: p.classifyTrigger(sessions), + Trigger: trigger, } if err := p.historyStore.Append(ctx, snapshot); err != nil { slog.Error("failed to record snapshot", "error", err) + return + } + p.lastRecordedAt = time.Now() +} + +// trimSessions bounds a periodic snapshot's session list to maxSessions, +// keeping the longest-running sessions when there are more active sessions +// than the cap allows — this keeps a periodic snapshot's size (and +// therefore disk growth) independent of how busy the monitored database +// is. Incident snapshots are never trimmed: understanding a blocking chain +// requires every session involved, not just the longest-running ones. +func trimSessions(sessions []domain.Session, trigger domain.SnapshotTrigger, maxSessions int) []domain.Session { + if trigger != domain.SnapshotTriggerPeriodic || len(sessions) <= maxSessions { + return sessions } + + sorted := make([]domain.Session, len(sessions)) + copy(sorted, sessions) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Duration > sorted[j].Duration + }) + + return sorted[:maxSessions] } // classifyTrigger marks this tick as an incident if any session became @@ -181,8 +198,8 @@ func (p *Poller) classifyTrigger(sessions []domain.Session) domain.SnapshotTrigg return trigger } -func (p *Poller) RecentHistory(ctx context.Context) ([]domain.Snapshot, error) { - return p.historyStore.Recent(ctx) +func (p *Poller) RecentHistory(ctx context.Context, since time.Time) ([]domain.Snapshot, error) { + return p.historyStore.Recent(ctx, since) } func (p *Poller) Incidents(ctx context.Context) ([]domain.Snapshot, error) { diff --git a/go/internal/domain/checkpoint_health_test.go b/go/internal/domain/checkpoint_health_test.go new file mode 100644 index 0000000..07ead14 --- /dev/null +++ b/go/internal/domain/checkpoint_health_test.go @@ -0,0 +1,79 @@ +package domain + +import "testing" + +func TestNewCheckpointHealth(t *testing.T) { + tests := []struct { + name string + stats CheckpointStats + wantRatio float64 + wantWarning bool + }{ + { + name: "no checkpoints at all yields zero ratio and no warning", + stats: CheckpointStats{ScheduledCheckpoints: 0, RequestedCheckpoints: 0}, + wantRatio: 0, + wantWarning: false, + }, + { + name: "all scheduled, none forced, is healthy", + stats: CheckpointStats{ScheduledCheckpoints: 100, RequestedCheckpoints: 0}, + wantRatio: 0, + wantWarning: false, + }, + { + name: "all forced, none scheduled, is one hundred percent", + stats: CheckpointStats{ScheduledCheckpoints: 0, RequestedCheckpoints: 100}, + wantRatio: 100, + wantWarning: true, + }, + { + name: "below the warning threshold has a ratio but no warning", + stats: CheckpointStats{ScheduledCheckpoints: 95, RequestedCheckpoints: 5}, // 5% + wantRatio: 5, + wantWarning: false, + }, + { + name: "exactly at the warning threshold triggers a warning", + stats: CheckpointStats{ScheduledCheckpoints: 90, RequestedCheckpoints: 10}, // exactly 10% + wantRatio: 10, + wantWarning: true, + }, + { + name: "above the warning threshold triggers a warning", + stats: CheckpointStats{ScheduledCheckpoints: 50, RequestedCheckpoints: 50}, // 50% + wantRatio: 50, + wantWarning: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewCheckpointHealth(tt.stats) + + if got.RequestedRatio != tt.wantRatio { + t.Errorf("RequestedRatio = %v, want %v", got.RequestedRatio, tt.wantRatio) + } + hasWarning := got.Warning != "" + if hasWarning != tt.wantWarning { + t.Errorf("Warning present = %v (%q), want present = %v", hasWarning, got.Warning, tt.wantWarning) + } + if got.ScheduledCheckpoints != tt.stats.ScheduledCheckpoints { + t.Errorf("ScheduledCheckpoints = %d, want %d", got.ScheduledCheckpoints, tt.stats.ScheduledCheckpoints) + } + if got.RequestedCheckpoints != tt.stats.RequestedCheckpoints { + t.Errorf("RequestedCheckpoints = %d, want %d", got.RequestedCheckpoints, tt.stats.RequestedCheckpoints) + } + }) + } +} + +func TestNewCheckpointHealth_WarningContent(t *testing.T) { + got := NewCheckpointHealth(CheckpointStats{ScheduledCheckpoints: 20, RequestedCheckpoints: 80}) + + for _, want := range []string{"80%", "80 of 100", "max_wal_size"} { + if !contains(got.Warning, want) { + t.Errorf("Warning = %q, want it to contain %q", got.Warning, want) + } + } +} diff --git a/go/internal/domain/connection_saturation_test.go b/go/internal/domain/connection_saturation_test.go new file mode 100644 index 0000000..0669274 --- /dev/null +++ b/go/internal/domain/connection_saturation_test.go @@ -0,0 +1,86 @@ +package domain + +import "testing" + +func TestNewConnectionSaturation(t *testing.T) { + tests := []struct { + name string + active int + max int + wantUsage float64 + wantWarning bool + }{ + { + name: "max is zero yields zero usage, not division by zero", + active: 10, + max: 0, + wantUsage: 0, + wantWarning: false, + }, + { + name: "no active connections yields zero usage", + active: 0, + max: 100, + wantUsage: 0, + wantWarning: false, + }, + { + name: "below the warning threshold has usage but no warning", + active: 50, + max: 100, + wantUsage: 50, + wantWarning: false, + }, + { + name: "exactly at the warning threshold triggers a warning", + active: 80, + max: 100, + wantUsage: 80, + wantWarning: true, + }, + { + name: "above the warning threshold triggers a warning", + active: 95, + max: 100, + wantUsage: 95, + wantWarning: true, + }, + { + name: "fully saturated is one hundred percent", + active: 100, + max: 100, + wantUsage: 100, + wantWarning: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewConnectionSaturation(tt.active, tt.max) + + if got.UsagePercent != tt.wantUsage { + t.Errorf("UsagePercent = %v, want %v", got.UsagePercent, tt.wantUsage) + } + hasWarning := got.Warning != "" + if hasWarning != tt.wantWarning { + t.Errorf("Warning present = %v (%q), want present = %v", hasWarning, got.Warning, tt.wantWarning) + } + if got.ActiveConnections != tt.active { + t.Errorf("ActiveConnections = %d, want %d", got.ActiveConnections, tt.active) + } + if got.MaxConnections != tt.max { + t.Errorf("MaxConnections = %d, want %d", got.MaxConnections, tt.max) + } + }) + } +} + +func TestNewConnectionSaturation_WarningContent(t *testing.T) { + got := NewConnectionSaturation(90, 100) + + for _, want := range []string{"90 of 100", "90%", "max_connections"} { + if !contains(got.Warning, want) { + t.Errorf("Warning = %q, want it to contain %q", got.Warning, want) + } + } +} diff --git a/go/internal/domain/database_stats.go b/go/internal/domain/database_stats.go index b3d5fa0..d82c1c8 100644 --- a/go/internal/domain/database_stats.go +++ b/go/internal/domain/database_stats.go @@ -10,8 +10,13 @@ import "time" // is the conventional way this metric is interpreted; a rate-of-change // version would be noisy and less meaningful than the running average. type DatabaseActivityStats struct { - CommitsPerSecond float64 `json:"commitsPerSecond"` - RollbacksPerSecond float64 `json:"rollbacksPerSecond"` - CacheHitRatio float64 `json:"cacheHitRatio"` + CommitsPerSecond float64 `json:"commitsPerSecond"` + RollbacksPerSecond float64 `json:"rollbacksPerSecond"` + CacheHitRatio float64 `json:"cacheHitRatio"` + // TempBytesPerSecond tracks disk spilling from sorts, hashes, and CTE + // materializations that don't fit in work_mem — a rising rate here + // usually points at a work_mem misconfiguration or a bad query plan, + // not something wrong with pgscope's own operation. + TempBytesPerSecond float64 `json:"tempBytesPerSecond"` MeasuredAt time.Time `json:"measuredAt"` } diff --git a/go/internal/domain/duplicate_index_test.go b/go/internal/domain/duplicate_index_test.go new file mode 100644 index 0000000..455613d --- /dev/null +++ b/go/internal/domain/duplicate_index_test.go @@ -0,0 +1,180 @@ +package domain + +import "testing" + +func TestDetectDuplicateIndexes(t *testing.T) { + tests := []struct { + name string + indexes []IndexInfo + want []struct{ redundant, covering string } + }{ + { + name: "identical column lists flags the alphabetically later index as redundant", + indexes: []IndexInfo{ + {Table: "orders", Name: "idx_a", AccessMethod: "btree", Columns: []string{"user_id"}}, + {Table: "orders", Name: "idx_b", AccessMethod: "btree", Columns: []string{"user_id"}}, + }, + want: []struct{ redundant, covering string }{ + {redundant: "idx_b", covering: "idx_a"}, + }, + }, + { + name: "a leading-column subset is redundant against a superset index", + indexes: []IndexInfo{ + {Table: "orders", Name: "idx_user", AccessMethod: "btree", Columns: []string{"user_id"}}, + {Table: "orders", Name: "idx_user_status", AccessMethod: "btree", Columns: []string{"user_id", "status"}}, + }, + want: []struct{ redundant, covering string }{ + {redundant: "idx_user", covering: "idx_user_status"}, + }, + }, + { + name: "same columns in a different order are not considered redundant", + indexes: []IndexInfo{ + {Table: "orders", Name: "idx_a", AccessMethod: "btree", Columns: []string{"user_id", "status"}}, + {Table: "orders", Name: "idx_b", AccessMethod: "btree", Columns: []string{"status", "user_id"}}, + }, + want: nil, + }, + { + name: "a column subset that isn't a leading prefix is not redundant", + indexes: []IndexInfo{ + {Table: "orders", Name: "idx_status_only", AccessMethod: "btree", Columns: []string{"status"}}, + {Table: "orders", Name: "idx_user_status", AccessMethod: "btree", Columns: []string{"user_id", "status"}}, + }, + want: nil, + }, + { + name: "primary key index is never flagged as redundant, even when another index covers it", + indexes: []IndexInfo{ + {Table: "orders", Name: "orders_pkey", AccessMethod: "btree", Columns: []string{"id"}, Primary: true}, + {Table: "orders", Name: "idx_id_created", AccessMethod: "btree", Columns: []string{"id", "created_at"}}, + }, + want: nil, + }, + { + name: "unique index is never flagged as redundant, even when another index covers it", + indexes: []IndexInfo{ + {Table: "users", Name: "users_email_key", AccessMethod: "btree", Columns: []string{"email"}, Unique: true}, + {Table: "users", Name: "idx_email_created", AccessMethod: "btree", Columns: []string{"email", "created_at"}}, + }, + want: nil, + }, + { + name: "a plain index covered by a primary key is flagged, and the primary key is the keeper", + indexes: []IndexInfo{ + {Table: "orders", Name: "idx_id_dup", AccessMethod: "btree", Columns: []string{"id"}}, + {Table: "orders", Name: "orders_pkey", AccessMethod: "btree", Columns: []string{"id"}, Primary: true}, + }, + want: []struct{ redundant, covering string }{ + {redundant: "idx_id_dup", covering: "orders_pkey"}, + }, + }, + { + name: "different access methods are never considered duplicates", + indexes: []IndexInfo{ + {Table: "orders", Name: "idx_btree", AccessMethod: "btree", Columns: []string{"user_id"}}, + {Table: "orders", Name: "idx_hash", AccessMethod: "hash", Columns: []string{"user_id"}}, + }, + want: nil, + }, + { + name: "same columns but different partial predicates are never considered duplicates", + indexes: []IndexInfo{ + {Table: "orders", Name: "idx_active", AccessMethod: "btree", Columns: []string{"user_id"}, Partial: true, Predicate: "status = 'active'"}, + {Table: "orders", Name: "idx_cancelled", AccessMethod: "btree", Columns: []string{"user_id"}, Partial: true, Predicate: "status = 'cancelled'"}, + }, + want: nil, + }, + { + name: "identical indexes on different tables are evaluated independently", + indexes: []IndexInfo{ + {Table: "orders", Name: "idx_a", AccessMethod: "btree", Columns: []string{"user_id"}}, + {Table: "orders", Name: "idx_b", AccessMethod: "btree", Columns: []string{"user_id"}}, + {Table: "payments", Name: "idx_c", AccessMethod: "btree", Columns: []string{"user_id"}}, + }, + want: []struct{ redundant, covering string }{ + {redundant: "idx_b", covering: "idx_a"}, + }, + }, + { + name: "a lone index with no siblings is never flagged", + indexes: []IndexInfo{ + {Table: "orders", Name: "idx_only", AccessMethod: "btree", Columns: []string{"user_id"}}, + }, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectDuplicateIndexes(tt.indexes) + + if len(got) != len(tt.want) { + t.Fatalf("DetectDuplicateIndexes() returned %d duplicates, want %d (%+v)", len(got), len(tt.want), got) + } + for i, w := range tt.want { + if got[i].RedundantIndex != w.redundant || got[i].CoveringIndex != w.covering { + t.Errorf("duplicate[%d] = (redundant=%q, covering=%q), want (redundant=%q, covering=%q)", + i, got[i].RedundantIndex, got[i].CoveringIndex, w.redundant, w.covering) + } + } + }) + } +} + +func TestDetectDuplicateIndexes_NeverReturnsNilSlice(t *testing.T) { + got := DetectDuplicateIndexes(nil) + if got == nil { + t.Fatal("DetectDuplicateIndexes(nil) returned a nil slice, want an empty non-nil slice") + } + if len(got) != 0 { + t.Fatalf("DetectDuplicateIndexes(nil) = %v, want empty", got) + } +} + +func TestDetectDuplicateIndexes_Explanation(t *testing.T) { + t.Run("mentions the primary key when the covering index backs it", func(t *testing.T) { + got := DetectDuplicateIndexes([]IndexInfo{ + {Table: "orders", Name: "idx_id_dup", AccessMethod: "btree", Columns: []string{"id"}}, + {Table: "orders", Name: "orders_pkey", AccessMethod: "btree", Columns: []string{"id"}, Primary: true}, + }) + + if len(got) != 1 { + t.Fatalf("expected exactly 1 duplicate, got %d", len(got)) + } + if want := "backs the primary key"; !contains(got[0].Explanation, want) { + t.Errorf("Explanation = %q, want it to contain %q", got[0].Explanation, want) + } + }) + + t.Run("mentions the unique constraint when the covering index enforces one", func(t *testing.T) { + got := DetectDuplicateIndexes([]IndexInfo{ + {Table: "users", Name: "idx_email_dup", AccessMethod: "btree", Columns: []string{"email"}}, + {Table: "users", Name: "users_email_key", AccessMethod: "btree", Columns: []string{"email"}, Unique: true}, + }) + + if len(got) != 1 { + t.Fatalf("expected exactly 1 duplicate, got %d", len(got)) + } + if want := "enforces a unique constraint"; !contains(got[0].Explanation, want) { + t.Errorf("Explanation = %q, want it to contain %q", got[0].Explanation, want) + } + }) + + t.Run("names both indexes and their columns when neither is primary or unique", func(t *testing.T) { + got := DetectDuplicateIndexes([]IndexInfo{ + {Table: "orders", Name: "idx_user", AccessMethod: "btree", Columns: []string{"user_id"}}, + {Table: "orders", Name: "idx_user_status", AccessMethod: "btree", Columns: []string{"user_id", "status"}}, + }) + + if len(got) != 1 { + t.Fatalf("expected exactly 1 duplicate, got %d", len(got)) + } + for _, want := range []string{"idx_user", "idx_user_status", "user_id", "status"} { + 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/function_cost_test.go b/go/internal/domain/function_cost_test.go new file mode 100644 index 0000000..4a56ea2 --- /dev/null +++ b/go/internal/domain/function_cost_test.go @@ -0,0 +1,158 @@ +package domain + +import "testing" + +func TestDetectExpensiveFunctions(t *testing.T) { + tests := []struct { + name string + stats []FunctionCallStats + want []string // function names expected in the result, in order + }{ + { + name: "below the minimum call count is ignored even with high self-time", + stats: []FunctionCallStats{ + {Function: "rare_fn", Calls: 10, SelfTimeMs: 1000}, // 100ms/call + }, + want: nil, + }, + { + name: "at exactly the minimum call count qualifies", + stats: []FunctionCallStats{ + {Function: "at_floor_fn", Calls: MinCallsForFunctionCostWarning, SelfTimeMs: 1000}, // 10ms/call + }, + want: []string{"at_floor_fn"}, + }, + { + name: "below the minimum self-time per call is ignored", + stats: []FunctionCallStats{ + {Function: "fast_fn", Calls: 1000, SelfTimeMs: 1000}, // 1ms/call + }, + want: nil, + }, + { + name: "at exactly the minimum self-time per call qualifies", + stats: []FunctionCallStats{ + {Function: "at_threshold_fn", Calls: 1000, SelfTimeMs: 5000}, // exactly 5ms/call + }, + want: []string{"at_threshold_fn"}, + }, + { + name: "above the minimum self-time per call qualifies", + stats: []FunctionCallStats{ + {Function: "slow_fn", Calls: 1000, SelfTimeMs: 20000}, // 20ms/call + }, + want: []string{"slow_fn"}, + }, + { + name: "a function with zero calls is ignored, not treated as infinite self-time", + stats: []FunctionCallStats{ + {Function: "never_called_fn", Calls: 0, SelfTimeMs: 0}, + }, + want: nil, + }, + { + name: "mixed input returns only the qualifying functions, preserving input order", + stats: []FunctionCallStats{ + {Function: "fine_fn", Calls: 1000, SelfTimeMs: 500}, // 0.5ms/call + {Function: "slow_one", Calls: 1000, SelfTimeMs: 20000}, // 20ms/call + {Function: "too_rare", Calls: 5, SelfTimeMs: 1000}, // qualifies on time, not calls + {Function: "slow_two", Calls: 2000, SelfTimeMs: 30000}, // 15ms/call + }, + want: []string{"slow_one", "slow_two"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectExpensiveFunctions(tt.stats) + + if len(got) != len(tt.want) { + t.Fatalf("DetectExpensiveFunctions() returned %d results, want %d (%+v)", len(got), len(tt.want), got) + } + for i, name := range tt.want { + if got[i].Function != name { + t.Errorf("result[%d].Function = %q, want %q", i, got[i].Function, name) + } + } + }) + } +} + +func TestDetectExpensiveFunctions_NeverReturnsNilSlice(t *testing.T) { + got := DetectExpensiveFunctions(nil) + if got == nil { + t.Fatal("DetectExpensiveFunctions(nil) returned a nil slice, want an empty non-nil slice") + } + if len(got) != 0 { + t.Fatalf("DetectExpensiveFunctions(nil) = %v, want empty", got) + } +} + +func TestDetectExpensiveFunctions_Explanation(t *testing.T) { + t.Run("mentions trigger tables and the shared-average caveat when IsTrigger is true", func(t *testing.T) { + got := DetectExpensiveFunctions([]FunctionCallStats{ + { + Function: "audit_log_fn", + Calls: 1000, + SelfTimeMs: 20000, + IsTrigger: true, + TriggerTables: []string{"orders", "payments"}, + }, + }) + + if len(got) != 1 { + t.Fatalf("expected exactly 1 result, got %d", len(got)) + } + for _, want := range []string{"used as a trigger", "orders, payments", "can't separate the two"} { + if !contains(got[0].Explanation, want) { + t.Errorf("Explanation = %q, want it to contain %q", got[0].Explanation, want) + } + } + }) + + t.Run("does not mention triggers when IsTrigger is false", func(t *testing.T) { + got := DetectExpensiveFunctions([]FunctionCallStats{ + {Function: "plain_fn", Calls: 1000, SelfTimeMs: 20000}, + }) + + if len(got) != 1 { + t.Fatalf("expected exactly 1 result, got %d", len(got)) + } + if contains(got[0].Explanation, "trigger") { + t.Errorf("Explanation = %q, want it to not mention triggers", got[0].Explanation) + } + }) +} + +func TestFunctionCallStats_SelfTimePerCall(t *testing.T) { + tests := []struct { + name string + stats FunctionCallStats + want float64 + }{ + { + name: "zero calls yields zero, not division by zero", + stats: FunctionCallStats{ + Calls: 0, + SelfTimeMs: 500, + }, + want: 0, + }, + { + name: "even split across calls", + stats: FunctionCallStats{ + Calls: 100, + SelfTimeMs: 1000, + }, + want: 10, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.stats.selfTimePerCall(); got != tt.want { + t.Errorf("selfTimePerCall() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/go/internal/domain/idle_in_transaction_test.go b/go/internal/domain/idle_in_transaction_test.go new file mode 100644 index 0000000..9c8dd7f --- /dev/null +++ b/go/internal/domain/idle_in_transaction_test.go @@ -0,0 +1,90 @@ +package domain + +import "testing" + +func TestDetectIdleInTransactionWarnings(t *testing.T) { + tests := []struct { + name string + sessions []IdleInTransactionSession + want []int32 // PIDs expected in the result, in order + }{ + { + name: "below the warning threshold is ignored", + sessions: []IdleInTransactionSession{ + {PID: 1, User: "app", ApplicationName: "web", IdleSeconds: 59}, + }, + want: nil, + }, + { + name: "exactly at the warning threshold qualifies", + sessions: []IdleInTransactionSession{ + {PID: 2, User: "app", ApplicationName: "web", IdleSeconds: IdleInTransactionWarningSeconds}, + }, + want: []int32{2}, + }, + { + name: "above the warning threshold qualifies", + sessions: []IdleInTransactionSession{ + {PID: 3, User: "app", ApplicationName: "web", IdleSeconds: 300}, + }, + want: []int32{3}, + }, + { + name: "zero idle seconds is ignored", + sessions: []IdleInTransactionSession{ + {PID: 4, User: "app", ApplicationName: "web", IdleSeconds: 0}, + }, + want: nil, + }, + { + name: "mixed input returns only the qualifying sessions, preserving input order", + sessions: []IdleInTransactionSession{ + {PID: 10, User: "app", ApplicationName: "web", IdleSeconds: 5}, + {PID: 11, User: "app", ApplicationName: "web", IdleSeconds: 120}, + {PID: 12, User: "app", ApplicationName: "web", IdleSeconds: 30}, + {PID: 13, User: "app", ApplicationName: "web", IdleSeconds: 600}, + }, + want: []int32{11, 13}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectIdleInTransactionWarnings(tt.sessions) + + if len(got) != len(tt.want) { + t.Fatalf("DetectIdleInTransactionWarnings() returned %d warnings, want %d (%+v)", len(got), len(tt.want), got) + } + for i, pid := range tt.want { + if got[i].PID != pid { + t.Errorf("warning[%d].PID = %d, want %d", i, got[i].PID, pid) + } + } + }) + } +} + +func TestDetectIdleInTransactionWarnings_NeverReturnsNilSlice(t *testing.T) { + got := DetectIdleInTransactionWarnings(nil) + if got == nil { + t.Fatal("DetectIdleInTransactionWarnings(nil) returned a nil slice, want an empty non-nil slice") + } + if len(got) != 0 { + t.Fatalf("DetectIdleInTransactionWarnings(nil) = %v, want empty", got) + } +} + +func TestDetectIdleInTransactionWarnings_Explanation(t *testing.T) { + got := DetectIdleInTransactionWarnings([]IdleInTransactionSession{ + {PID: 42, User: "worker", ApplicationName: "batch-job", IdleSeconds: 180}, + }) + + if len(got) != 1 { + t.Fatalf("expected exactly 1 warning, got %d", len(got)) + } + for _, want := range []string{"42", "worker", "batch-job", "idle_in_transaction_session_timeout"} { + 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/index_candidate_rationale_test.go b/go/internal/domain/index_candidate_rationale_test.go new file mode 100644 index 0000000..08a0e4a --- /dev/null +++ b/go/internal/domain/index_candidate_rationale_test.go @@ -0,0 +1,146 @@ +package domain + +import "testing" + +func TestNewIndexCandidate_Fields(t *testing.T) { + signal := IndexSignal{EstimatedRows: 100000, SeqScan: 5000, IdxScan: 10} + + got := NewIndexCandidate("orders", signal, []string{"user_id"}, 2, 3.5) + + if got.Table != "orders" { + t.Errorf("Table = %q, want %q", got.Table, "orders") + } + if got.SeqScanCount != 5000 { + t.Errorf("SeqScanCount = %d, want %d", got.SeqScanCount, 5000) + } + if got.IdxScanCount != 10 { + t.Errorf("IdxScanCount = %d, want %d", got.IdxScanCount, 10) + } + if len(got.SuspectedColumns) != 1 || got.SuspectedColumns[0] != "user_id" { + t.Errorf("SuspectedColumns = %v, want [user_id]", got.SuspectedColumns) + } + if got.ExistingIndexCount != 2 { + t.Errorf("ExistingIndexCount = %d, want %d", got.ExistingIndexCount, 2) + } + if got.WritesPerSecond != 3.5 { + t.Errorf("WritesPerSecond = %v, want %v", got.WritesPerSecond, 3.5) + } + if got.Confidence != ConfidenceStrong { + t.Errorf("Confidence = %v, want %v", got.Confidence, ConfidenceStrong) + } +} + +func TestNewIndexCandidate_SelectivityPointer(t *testing.T) { + t.Run("nil when NDistinct is unknown", func(t *testing.T) { + signal := IndexSignal{EstimatedRows: 100000, SeqScan: 5000, IdxScan: 10} + got := NewIndexCandidate("orders", signal, nil, 0, 0) + if got.Selectivity != nil { + t.Errorf("Selectivity = %v, want nil", *got.Selectivity) + } + }) + + t.Run("set when NDistinct is known", func(t *testing.T) { + ndistinct := 90000.0 + signal := IndexSignal{EstimatedRows: 100000, SeqScan: 5000, IdxScan: 10, NDistinct: &ndistinct} + got := NewIndexCandidate("orders", signal, nil, 0, 0) + if got.Selectivity == nil { + t.Fatal("Selectivity = nil, want a value") + } + want := 1.0 / 90000 + if *got.Selectivity < want*0.99 || *got.Selectivity > want*1.01 { + t.Errorf("Selectivity = %v, want approx %v", *got.Selectivity, want) + } + }) +} + +func TestNewIndexCandidate_Rationale(t *testing.T) { + tests := []struct { + name string + signal IndexSignal + suspectedColumns []string + existingIndexCount int + writesPerSecond float64 + wantContains []string + wantNotContains []string + }{ + { + name: "strong confidence mentions the strong signal wording", + signal: IndexSignal{EstimatedRows: 100000, SeqScan: 5000, IdxScan: 10}, + suspectedColumns: []string{"user_id"}, + wantContains: []string{"orders", "5000 sequential scans", "10 index scans", "filtering on user_id", "strong, high-volume signal"}, + }, + { + name: "weak confidence mentions the smaller-sample wording", + signal: IndexSignal{EstimatedRows: 100000, SeqScan: 150, IdxScan: 10}, + wantContains: []string{"smaller sample"}, + }, + { + name: "insufficient confidence mentions not enough data", + signal: IndexSignal{EstimatedRows: 500, SeqScan: 10000, IdxScan: 1}, + wantContains: []string{"Not enough data yet"}, + }, + { + name: "no suspected columns omits the filtering-on phrase", + signal: IndexSignal{EstimatedRows: 100000, SeqScan: 5000, IdxScan: 10}, + suspectedColumns: nil, + wantNotContains: []string{"filtering on"}, + }, + { + name: "poor selectivity mentions low selectivity favors-caution wording", + signal: IndexSignal{ + EstimatedRows: 100000, SeqScan: 5000, IdxScan: 10, + NDistinct: floatPtr(4), // selectivity 0.25, above the 0.05 poor threshold + }, + wantContains: []string{"low selectivity", "may not help much"}, + }, + { + name: "good selectivity mentions the favors-an-index wording", + signal: IndexSignal{ + EstimatedRows: 100000, SeqScan: 5000, IdxScan: 10, + NDistinct: floatPtr(90000), // selectivity ~0.0000111, well below 0.05 + }, + wantContains: []string{"looks selective", "favors an index"}, + }, + { + name: "zero existing indexes is called out explicitly", + signal: IndexSignal{EstimatedRows: 100000, SeqScan: 5000, IdxScan: 10}, + existingIndexCount: 0, + wantContains: []string{"no indexes at all"}, + }, + { + name: "nonzero existing indexes omits the no-indexes wording", + signal: IndexSignal{EstimatedRows: 100000, SeqScan: 5000, IdxScan: 10}, + existingIndexCount: 3, + wantNotContains: []string{"no indexes at all"}, + }, + { + name: "write volume above the threshold warns about overhead", + signal: IndexSignal{EstimatedRows: 100000, SeqScan: 5000, IdxScan: 10}, + writesPerSecond: 5.0, + wantContains: []string{"writes/s", "add real overhead"}, + }, + { + name: "low write volume is called low-risk", + signal: IndexSignal{EstimatedRows: 100000, SeqScan: 5000, IdxScan: 10}, + writesPerSecond: 0.5, + wantContains: []string{"Write volume looks low", "low-risk"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewIndexCandidate("orders", tt.signal, tt.suspectedColumns, tt.existingIndexCount, tt.writesPerSecond) + + for _, want := range tt.wantContains { + if !contains(got.Rationale, want) { + t.Errorf("Rationale = %q, want it to contain %q", got.Rationale, want) + } + } + for _, notWant := range tt.wantNotContains { + if contains(got.Rationale, notWant) { + t.Errorf("Rationale = %q, want it to NOT contain %q", got.Rationale, notWant) + } + } + }) + } +} diff --git a/go/internal/domain/insights.go b/go/internal/domain/insights.go index e2ccad1..65fab53 100644 --- a/go/internal/domain/insights.go +++ b/go/internal/domain/insights.go @@ -29,25 +29,29 @@ type IndexCandidate struct { // Insights bundles every category of suggestion returned by one snapshot // of analysis. Always advisory — pgscope never applies anything itself. type Insights struct { - TopQueries []SlowQuery `json:"topQueries"` - IndexCandidates []IndexCandidate `json:"indexCandidates"` - DuplicateIndexes []DuplicateIndex `json:"duplicateIndexes"` - UnusedIndexes []UnusedIndex `json:"unusedIndexes"` - FunctionCosts []FunctionCost `json:"functionCosts"` - TrackFunctionsEnabled bool `json:"trackFunctionsEnabled"` - TrackFunctionsSetting string `json:"trackFunctionsSetting"` - PaginationWarnings []PaginationWarning `json:"paginationWarnings"` - NestedStatementsTracked bool `json:"nestedStatementsTracked"` - StatementsTrackSetting string `json:"statementsTrackSetting"` - DatabaseSize DatabaseSizeInfo `json:"databaseSize"` - ConnectionSaturation ConnectionSaturation `json:"connectionSaturation"` - SequenceOverflowRisks []SequenceOverflowRisk `json:"sequenceOverflowRisks"` - InvalidIndexes []InvalidIndex `json:"invalidIndexes"` - UnvalidatedConstraints []UnvalidatedConstraint `json:"unvalidatedConstraints"` - VacuumHealthWarnings []VacuumHealthWarning `json:"vacuumHealthWarnings"` - IdleInTransactionWarnings []IdleInTransactionWarning `json:"idleInTransactionWarnings"` - CheckpointHealth CheckpointHealth `json:"checkpointHealth"` - ReplicationLagWarnings []ReplicationLagWarning `json:"replicationLagWarnings"` - PhysicalIOEnabled bool `json:"physicalIOEnabled"` - PhysicalIOHotspots []PhysicalIOHotspot `json:"physicalIOHotspots"` + TopQueries []SlowQuery `json:"topQueries"` + IndexCandidates []IndexCandidate `json:"indexCandidates"` + DuplicateIndexes []DuplicateIndex `json:"duplicateIndexes"` + UnusedIndexes []UnusedIndex `json:"unusedIndexes"` + FunctionCosts []FunctionCost `json:"functionCosts"` + TrackFunctionsEnabled bool `json:"trackFunctionsEnabled"` + TrackFunctionsSetting string `json:"trackFunctionsSetting"` + PaginationWarnings []PaginationWarning `json:"paginationWarnings"` + NestedStatementsTracked bool `json:"nestedStatementsTracked"` + StatementsTrackSetting string `json:"statementsTrackSetting"` + DatabaseSize DatabaseSizeInfo `json:"databaseSize"` + ConnectionSaturation ConnectionSaturation `json:"connectionSaturation"` + SequenceOverflowRisks []SequenceOverflowRisk `json:"sequenceOverflowRisks"` + InvalidIndexes []InvalidIndex `json:"invalidIndexes"` + UnvalidatedConstraints []UnvalidatedConstraint `json:"unvalidatedConstraints"` + VacuumHealthWarnings []VacuumHealthWarning `json:"vacuumHealthWarnings"` + IdleInTransactionWarnings []IdleInTransactionWarning `json:"idleInTransactionWarnings"` + CheckpointHealth CheckpointHealth `json:"checkpointHealth"` + ReplicationLagWarnings []ReplicationLagWarning `json:"replicationLagWarnings"` + PhysicalIOEnabled bool `json:"physicalIOEnabled"` + PhysicalIOHotspots []PhysicalIOHotspot `json:"physicalIOHotspots"` + PreparedTransactionWarnings []PreparedTransactionWarning `json:"preparedTransactionWarnings"` + ReplicationSlotWarnings []ReplicationSlotWarning `json:"replicationSlotWarnings"` + LongRunningQueryWarnings []LongRunningQueryWarning `json:"longRunningQueryWarnings"` + UnloggedTables []UnloggedTable `json:"unloggedTables"` } diff --git a/go/internal/domain/long_running_query.go b/go/internal/domain/long_running_query.go new file mode 100644 index 0000000..317d4c6 --- /dev/null +++ b/go/internal/domain/long_running_query.go @@ -0,0 +1,56 @@ +package domain + +import "fmt" + +const LongRunningQueryWarningSeconds = 300.0 + +// LongRunningQuerySession is the raw shape read from pg_stat_activity for +// one session with state = 'active' — no judgment applied yet. Unlike +// IdleInTransactionSession, this session is actually executing a query +// right now, not waiting on its client. +type LongRunningQuerySession struct { + PID int32 + User string + ApplicationName string + Query string + RunningSeconds float64 +} + +// LongRunningQueryWarning is a suggestion, never a certainty — some +// queries (large reports, batch jobs, maintenance tasks) are legitimately +// long-running by design. A query stuck in 'active' for a while without +// waiting on a lock usually signals a plan gone bad (e.g. a nested loop +// over huge tables), worth a look either way. +type LongRunningQueryWarning struct { + PID int32 `json:"pid"` + User string `json:"user"` + ApplicationName string `json:"applicationName"` + Query string `json:"query"` + RunningSeconds float64 `json:"runningSeconds"` + Explanation string `json:"explanation"` +} + +// DetectLongRunningQueryWarnings filters active sessions down to the ones +// running long enough to matter — most queries finish in milliseconds, so +// this floor is set high enough to only flag genuine outliers. +func DetectLongRunningQueryWarnings(sessions []LongRunningQuerySession) []LongRunningQueryWarning { + result := make([]LongRunningQueryWarning, 0) + for _, s := range sessions { + if s.RunningSeconds < LongRunningQueryWarningSeconds { + continue + } + + result = append(result, LongRunningQueryWarning{ + PID: s.PID, + User: s.User, + ApplicationName: s.ApplicationName, + Query: s.Query, + RunningSeconds: s.RunningSeconds, + Explanation: fmt.Sprintf( + "Session %d (user %q, app %q) has been actively running the same query for %.0f seconds. This may be an expected long-running job (a report, batch job, or maintenance task), but if it's unexpected, it often signals a bad query plan (e.g. a sequential or nested-loop scan over a much larger table than intended). The query text shown here is normalized (literal values replaced with $1, $2, ...) — to see the real query with its actual values, connect to the database yourself and run: SELECT query, wait_event_type, wait_event FROM pg_stat_activity WHERE pid = %d.", + s.PID, s.User, s.ApplicationName, s.RunningSeconds, s.PID, + ), + }) + } + return result +} diff --git a/go/internal/domain/long_running_query_test.go b/go/internal/domain/long_running_query_test.go new file mode 100644 index 0000000..1b9403e --- /dev/null +++ b/go/internal/domain/long_running_query_test.go @@ -0,0 +1,90 @@ +package domain + +import "testing" + +func TestDetectLongRunningQueryWarnings(t *testing.T) { + tests := []struct { + name string + sessions []LongRunningQuerySession + want []int32 // PIDs expected in the result, in order + }{ + { + name: "below the warning threshold is ignored", + sessions: []LongRunningQuerySession{ + {PID: 1, User: "app", ApplicationName: "web", Query: "SELECT 1", RunningSeconds: 10}, + }, + want: nil, + }, + { + name: "exactly at the warning threshold qualifies", + sessions: []LongRunningQuerySession{ + {PID: 2, User: "app", ApplicationName: "web", Query: "SELECT 1", RunningSeconds: LongRunningQueryWarningSeconds}, + }, + want: []int32{2}, + }, + { + name: "above the warning threshold qualifies", + sessions: []LongRunningQuerySession{ + {PID: 3, User: "app", ApplicationName: "web", Query: "SELECT 1", RunningSeconds: 900}, + }, + want: []int32{3}, + }, + { + name: "zero running seconds is ignored", + sessions: []LongRunningQuerySession{ + {PID: 4, User: "app", ApplicationName: "web", Query: "SELECT 1", RunningSeconds: 0}, + }, + want: nil, + }, + { + name: "mixed input returns only the qualifying sessions, preserving input order", + sessions: []LongRunningQuerySession{ + {PID: 10, User: "app", ApplicationName: "web", Query: "SELECT 1", RunningSeconds: 1}, + {PID: 11, User: "app", ApplicationName: "web", Query: "SELECT 2", RunningSeconds: 600}, + {PID: 12, User: "app", ApplicationName: "web", Query: "SELECT 3", RunningSeconds: 30}, + {PID: 13, User: "app", ApplicationName: "web", Query: "SELECT 4", RunningSeconds: 1200}, + }, + want: []int32{11, 13}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectLongRunningQueryWarnings(tt.sessions) + + if len(got) != len(tt.want) { + t.Fatalf("DetectLongRunningQueryWarnings() returned %d warnings, want %d (%+v)", len(got), len(tt.want), got) + } + for i, pid := range tt.want { + if got[i].PID != pid { + t.Errorf("warning[%d].PID = %d, want %d", i, got[i].PID, pid) + } + } + }) + } +} + +func TestDetectLongRunningQueryWarnings_NeverReturnsNilSlice(t *testing.T) { + got := DetectLongRunningQueryWarnings(nil) + if got == nil { + t.Fatal("DetectLongRunningQueryWarnings(nil) returned a nil slice, want an empty non-nil slice") + } + if len(got) != 0 { + t.Fatalf("DetectLongRunningQueryWarnings(nil) = %v, want empty", got) + } +} + +func TestDetectLongRunningQueryWarnings_Explanation(t *testing.T) { + got := DetectLongRunningQueryWarnings([]LongRunningQuerySession{ + {PID: 42, User: "reporting", ApplicationName: "nightly-job", Query: "SELECT * FROM orders", RunningSeconds: 620}, + }) + + if len(got) != 1 { + t.Fatalf("expected exactly 1 warning, got %d", len(got)) + } + for _, want := range []string{"42", "reporting", "nightly-job", "620", "normalized", "pg_stat_activity WHERE pid = 42"} { + 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/physical_io_test.go b/go/internal/domain/physical_io_test.go new file mode 100644 index 0000000..28728e6 --- /dev/null +++ b/go/internal/domain/physical_io_test.go @@ -0,0 +1,97 @@ +package domain + +import "testing" + +func TestDetectPhysicalIOHotspots(t *testing.T) { + tests := []struct { + name string + stats []QueryPhysicalIO + want []string // queries expected in the result, in order + }{ + { + name: "below the minimum exec reads is ignored", + stats: []QueryPhysicalIO{ + {Query: "cached_query", ExecReads: 999}, + }, + want: nil, + }, + { + name: "exactly at the minimum exec reads qualifies", + stats: []QueryPhysicalIO{ + {Query: "at_floor_query", ExecReads: MinExecReadsForKcacheWarning}, + }, + want: []string{"at_floor_query"}, + }, + { + name: "above the minimum exec reads qualifies", + stats: []QueryPhysicalIO{ + {Query: "hotspot_query", ExecReads: 50000}, + }, + want: []string{"hotspot_query"}, + }, + { + name: "zero exec reads is ignored", + stats: []QueryPhysicalIO{ + {Query: "no_reads_query", ExecReads: 0}, + }, + want: nil, + }, + { + name: "mixed input returns only the qualifying queries, preserving input order", + stats: []QueryPhysicalIO{ + {Query: "fine_query", ExecReads: 100}, + {Query: "hot_one", ExecReads: 5000}, + {Query: "also_fine", ExecReads: 500}, + {Query: "hot_two", ExecReads: 10000}, + }, + want: []string{"hot_one", "hot_two"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectPhysicalIOHotspots(tt.stats) + + if len(got) != len(tt.want) { + t.Fatalf("DetectPhysicalIOHotspots() returned %d results, want %d (%+v)", len(got), len(tt.want), got) + } + for i, q := range tt.want { + if got[i].Query != q { + t.Errorf("result[%d].Query = %q, want %q", i, got[i].Query, q) + } + } + }) + } +} + +func TestDetectPhysicalIOHotspots_NeverReturnsNilSlice(t *testing.T) { + got := DetectPhysicalIOHotspots(nil) + if got == nil { + t.Fatal("DetectPhysicalIOHotspots(nil) returned a nil slice, want an empty non-nil slice") + } + if len(got) != 0 { + t.Fatalf("DetectPhysicalIOHotspots(nil) = %v, want empty", got) + } +} + +func TestDetectPhysicalIOHotspots_Explanation(t *testing.T) { + got := DetectPhysicalIOHotspots([]QueryPhysicalIO{ + { + Query: "select * from big_table", + Calls: 42, + ExecReads: 5000, + ExecWrites: 10, + UserTimeMs: 12.5, + SystemTimeMs: 3.2, + }, + }) + + if len(got) != 1 { + t.Fatalf("expected exactly 1 result, got %d", len(got)) + } + for _, want := range []string{"5000", "10", "42", "shared_buffers"} { + 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/prepared_transaction.go b/go/internal/domain/prepared_transaction.go new file mode 100644 index 0000000..4e18574 --- /dev/null +++ b/go/internal/domain/prepared_transaction.go @@ -0,0 +1,53 @@ +package domain + +import "fmt" + +const PreparedTransactionWarningSeconds = 600.0 + +// PreparedTransactionInfo is the raw shape read from pg_prepared_xacts for +// one two-phase-commit transaction still awaiting COMMIT PREPARED or +// ROLLBACK PREPARED — no judgment applied yet. +type PreparedTransactionInfo struct { + GID string + Database string + Owner string + AgeSeconds float64 +} + +// PreparedTransactionWarning is a suggestion, never a certainty — +// pg_prepared_xacts is expected to be empty almost all the time outside the +// brief window a two-phase commit coordinator is actively finishing up. One +// sitting there a while usually means the coordinator crashed or forgot to +// follow up, and it holds locks and blocks vacuum for as long as it stays +// open. +type PreparedTransactionWarning struct { + GID string `json:"gid"` + Database string `json:"database"` + Owner string `json:"owner"` + AgeSeconds float64 `json:"ageSeconds"` + Explanation string `json:"explanation"` +} + +// DetectPreparedTransactionWarnings filters prepared transactions down to +// the ones open long enough to matter — a transaction mid-commit for a +// second or two is a coordinator doing its job, not a signal. +func DetectPreparedTransactionWarnings(transactions []PreparedTransactionInfo) []PreparedTransactionWarning { + result := make([]PreparedTransactionWarning, 0) + for _, tx := range transactions { + if tx.AgeSeconds < PreparedTransactionWarningSeconds { + continue + } + + result = append(result, PreparedTransactionWarning{ + GID: tx.GID, + Database: tx.Database, + Owner: tx.Owner, + AgeSeconds: tx.AgeSeconds, + Explanation: fmt.Sprintf( + "Prepared transaction %q on database %q (owner %q) has been waiting %.0f seconds to be committed or rolled back. It's holding locks and blocking vacuum for as long as it stays open — likely a crashed or forgotten two-phase commit coordinator. Run COMMIT PREPARED or ROLLBACK PREPARED to resolve it.", + tx.GID, tx.Database, tx.Owner, tx.AgeSeconds, + ), + }) + } + return result +} diff --git a/go/internal/domain/prepared_transaction_test.go b/go/internal/domain/prepared_transaction_test.go new file mode 100644 index 0000000..4ca02ba --- /dev/null +++ b/go/internal/domain/prepared_transaction_test.go @@ -0,0 +1,90 @@ +package domain + +import "testing" + +func TestDetectPreparedTransactionWarnings(t *testing.T) { + tests := []struct { + name string + transactions []PreparedTransactionInfo + want []string // GIDs expected in the result, in order + }{ + { + name: "below the warning threshold is ignored", + transactions: []PreparedTransactionInfo{ + {GID: "tx_recent", Database: "app", Owner: "app_user", AgeSeconds: 5}, + }, + want: nil, + }, + { + name: "exactly at the warning threshold qualifies", + transactions: []PreparedTransactionInfo{ + {GID: "tx_at_threshold", Database: "app", Owner: "app_user", AgeSeconds: PreparedTransactionWarningSeconds}, + }, + want: []string{"tx_at_threshold"}, + }, + { + name: "above the warning threshold qualifies", + transactions: []PreparedTransactionInfo{ + {GID: "tx_stale", Database: "app", Owner: "app_user", AgeSeconds: 3600}, + }, + want: []string{"tx_stale"}, + }, + { + name: "zero age is ignored", + transactions: []PreparedTransactionInfo{ + {GID: "tx_fresh", Database: "app", Owner: "app_user", AgeSeconds: 0}, + }, + want: nil, + }, + { + name: "mixed input returns only the qualifying transactions, preserving input order", + transactions: []PreparedTransactionInfo{ + {GID: "tx_fine", Database: "app", Owner: "app_user", AgeSeconds: 2}, + {GID: "tx_stale_one", Database: "app", Owner: "app_user", AgeSeconds: 900}, + {GID: "tx_also_fine", Database: "app", Owner: "app_user", AgeSeconds: 30}, + {GID: "tx_stale_two", Database: "app", Owner: "app_user", AgeSeconds: 1800}, + }, + want: []string{"tx_stale_one", "tx_stale_two"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectPreparedTransactionWarnings(tt.transactions) + + if len(got) != len(tt.want) { + t.Fatalf("DetectPreparedTransactionWarnings() returned %d warnings, want %d (%+v)", len(got), len(tt.want), got) + } + for i, gid := range tt.want { + if got[i].GID != gid { + t.Errorf("warning[%d].GID = %q, want %q", i, got[i].GID, gid) + } + } + }) + } +} + +func TestDetectPreparedTransactionWarnings_NeverReturnsNilSlice(t *testing.T) { + got := DetectPreparedTransactionWarnings(nil) + if got == nil { + t.Fatal("DetectPreparedTransactionWarnings(nil) returned a nil slice, want an empty non-nil slice") + } + if len(got) != 0 { + t.Fatalf("DetectPreparedTransactionWarnings(nil) = %v, want empty", got) + } +} + +func TestDetectPreparedTransactionWarnings_Explanation(t *testing.T) { + got := DetectPreparedTransactionWarnings([]PreparedTransactionInfo{ + {GID: "tx_abandoned", Database: "orders_db", Owner: "batch_worker", AgeSeconds: 1200}, + }) + + if len(got) != 1 { + t.Fatalf("expected exactly 1 warning, got %d", len(got)) + } + for _, want := range []string{"tx_abandoned", "orders_db", "batch_worker", "1200", "COMMIT PREPARED", "ROLLBACK PREPARED"} { + 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/replication_lag_test.go b/go/internal/domain/replication_lag_test.go new file mode 100644 index 0000000..4a47882 --- /dev/null +++ b/go/internal/domain/replication_lag_test.go @@ -0,0 +1,90 @@ +package domain + +import "testing" + +func TestDetectReplicationLagWarnings(t *testing.T) { + tests := []struct { + name string + replicas []ReplicaLagInfo + want []string // application names expected in the result, in order + }{ + { + name: "below the warning threshold is ignored", + replicas: []ReplicaLagInfo{ + {ApplicationName: "replica1", LagBytes: ReplicationLagWarningBytes - 1}, + }, + want: nil, + }, + { + name: "exactly at the warning threshold qualifies", + replicas: []ReplicaLagInfo{ + {ApplicationName: "replica2", LagBytes: ReplicationLagWarningBytes}, + }, + want: []string{"replica2"}, + }, + { + name: "above the warning threshold qualifies", + replicas: []ReplicaLagInfo{ + {ApplicationName: "replica3", LagBytes: 500 * 1024 * 1024}, + }, + want: []string{"replica3"}, + }, + { + name: "zero lag is ignored", + replicas: []ReplicaLagInfo{ + {ApplicationName: "replica4", LagBytes: 0}, + }, + want: nil, + }, + { + name: "mixed input returns only the qualifying replicas, preserving input order", + replicas: []ReplicaLagInfo{ + {ApplicationName: "in_sync", LagBytes: 1024}, + {ApplicationName: "lagging_one", LagBytes: 200 * 1024 * 1024}, + {ApplicationName: "almost_synced", LagBytes: 1024 * 1024}, + {ApplicationName: "lagging_two", LagBytes: 300 * 1024 * 1024}, + }, + want: []string{"lagging_one", "lagging_two"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectReplicationLagWarnings(tt.replicas) + + if len(got) != len(tt.want) { + t.Fatalf("DetectReplicationLagWarnings() returned %d warnings, want %d (%+v)", len(got), len(tt.want), got) + } + for i, name := range tt.want { + if got[i].ApplicationName != name { + t.Errorf("warning[%d].ApplicationName = %q, want %q", i, got[i].ApplicationName, name) + } + } + }) + } +} + +func TestDetectReplicationLagWarnings_NeverReturnsNilSlice(t *testing.T) { + got := DetectReplicationLagWarnings(nil) + if got == nil { + t.Fatal("DetectReplicationLagWarnings(nil) returned a nil slice, want an empty non-nil slice") + } + if len(got) != 0 { + t.Fatalf("DetectReplicationLagWarnings(nil) = %v, want empty", got) + } +} + +func TestDetectReplicationLagWarnings_Explanation(t *testing.T) { + got := DetectReplicationLagWarnings([]ReplicaLagInfo{ + {ApplicationName: "replica_west", ClientAddr: "10.0.0.5", LagBytes: 250 * 1024 * 1024}, + }) + + if len(got) != 1 { + t.Fatalf("expected exactly 1 warning, got %d", len(got)) + } + for _, want := range []string{"replica_west", "10.0.0.5", "250 MB"} { + 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/replication_slot.go b/go/internal/domain/replication_slot.go new file mode 100644 index 0000000..d7b67b1 --- /dev/null +++ b/go/internal/domain/replication_slot.go @@ -0,0 +1,61 @@ +package domain + +import "fmt" + +const ReplicationSlotWarningBytes = 1024 * 1024 * 1024 // 1 GiB + +// ReplicationSlotInfo is the raw shape read from pg_replication_slots for +// one replication slot — no judgment applied yet. RetainedBytes is how much +// WAL the primary is holding onto specifically because of this slot, +// regardless of whether a replica is currently connected to consume it. +type ReplicationSlotInfo struct { + SlotName string + Active bool + RetainedBytes int64 +} + +// ReplicationSlotWarning is a suggestion, never a certainty — a slot +// retaining a lot of WAL is often transient (a replica catching up after a +// brief disconnect). This only flags slots holding onto enough WAL that, +// left unaddressed, risk filling the primary's disk — a different failure +// mode than replication lag itself (a slot with no connected replica at +// all still retains WAL, and wouldn't show up as "lag" anywhere). +type ReplicationSlotWarning struct { + SlotName string `json:"slotName"` + Active bool `json:"active"` + RetainedBytes int64 `json:"retainedBytes"` + Explanation string `json:"explanation"` +} + +// DetectReplicationSlotWarnings filters replication slots down to the ones +// retaining enough WAL to be worth a look. +func DetectReplicationSlotWarnings(slots []ReplicationSlotInfo) []ReplicationSlotWarning { + result := make([]ReplicationSlotWarning, 0) + for _, s := range slots { + if s.RetainedBytes < ReplicationSlotWarningBytes { + continue + } + + result = append(result, ReplicationSlotWarning{ + SlotName: s.SlotName, + Active: s.Active, + RetainedBytes: s.RetainedBytes, + Explanation: buildReplicationSlotExplanation(s), + }) + } + return result +} + +func buildReplicationSlotExplanation(s ReplicationSlotInfo) string { + retainedMB := s.RetainedBytes / (1024 * 1024) + if !s.Active { + return fmt.Sprintf( + "Replication slot %q is inactive (no replica currently connected) but is still retaining about %d MB of WAL. An inactive slot retains WAL indefinitely until either a replica consumes it or the slot is dropped — this can fill the primary's disk if left unaddressed. Confirm the slot is still needed before dropping it.", + s.SlotName, retainedMB, + ) + } + return fmt.Sprintf( + "Replication slot %q is retaining about %d MB of WAL for its replica. If this keeps growing, the replica may be falling behind or struggling to keep up — check its connectivity and load.", + s.SlotName, retainedMB, + ) +} diff --git a/go/internal/domain/replication_slot_test.go b/go/internal/domain/replication_slot_test.go new file mode 100644 index 0000000..5a52ea1 --- /dev/null +++ b/go/internal/domain/replication_slot_test.go @@ -0,0 +1,114 @@ +package domain + +import "testing" + +func TestDetectReplicationSlotWarnings(t *testing.T) { + tests := []struct { + name string + slots []ReplicationSlotInfo + want []string // slot names expected in the result, in order + }{ + { + name: "below the warning threshold is ignored", + slots: []ReplicationSlotInfo{ + {SlotName: "slot_a", Active: true, RetainedBytes: ReplicationSlotWarningBytes - 1}, + }, + want: nil, + }, + { + name: "exactly at the warning threshold qualifies", + slots: []ReplicationSlotInfo{ + {SlotName: "slot_b", Active: true, RetainedBytes: ReplicationSlotWarningBytes}, + }, + want: []string{"slot_b"}, + }, + { + name: "above the warning threshold qualifies", + slots: []ReplicationSlotInfo{ + {SlotName: "slot_c", Active: true, RetainedBytes: 2 * ReplicationSlotWarningBytes}, + }, + want: []string{"slot_c"}, + }, + { + name: "an inactive slot retaining a lot of WAL also qualifies", + slots: []ReplicationSlotInfo{ + {SlotName: "slot_orphaned", Active: false, RetainedBytes: 3 * ReplicationSlotWarningBytes}, + }, + want: []string{"slot_orphaned"}, + }, + { + name: "zero retained bytes is ignored", + slots: []ReplicationSlotInfo{ + {SlotName: "slot_fresh", Active: true, RetainedBytes: 0}, + }, + want: nil, + }, + { + name: "mixed input returns only the qualifying slots, preserving input order", + slots: []ReplicationSlotInfo{ + {SlotName: "healthy_slot", Active: true, RetainedBytes: 1024}, + {SlotName: "bloated_one", Active: true, RetainedBytes: 2 * ReplicationSlotWarningBytes}, + {SlotName: "also_healthy", Active: true, RetainedBytes: 1024 * 1024}, + {SlotName: "bloated_two", Active: false, RetainedBytes: 5 * ReplicationSlotWarningBytes}, + }, + want: []string{"bloated_one", "bloated_two"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectReplicationSlotWarnings(tt.slots) + + if len(got) != len(tt.want) { + t.Fatalf("DetectReplicationSlotWarnings() returned %d warnings, want %d (%+v)", len(got), len(tt.want), got) + } + for i, name := range tt.want { + if got[i].SlotName != name { + t.Errorf("warning[%d].SlotName = %q, want %q", i, got[i].SlotName, name) + } + } + }) + } +} + +func TestDetectReplicationSlotWarnings_NeverReturnsNilSlice(t *testing.T) { + got := DetectReplicationSlotWarnings(nil) + if got == nil { + t.Fatal("DetectReplicationSlotWarnings(nil) returned a nil slice, want an empty non-nil slice") + } + if len(got) != 0 { + t.Fatalf("DetectReplicationSlotWarnings(nil) = %v, want empty", got) + } +} + +func TestDetectReplicationSlotWarnings_Explanation(t *testing.T) { + t.Run("mentions the slot may be orphaned when inactive", func(t *testing.T) { + got := DetectReplicationSlotWarnings([]ReplicationSlotInfo{ + {SlotName: "old_replica_slot", Active: false, RetainedBytes: 2 * ReplicationSlotWarningBytes}, + }) + + if len(got) != 1 { + t.Fatalf("expected exactly 1 warning, got %d", len(got)) + } + for _, want := range []string{"old_replica_slot", "inactive", "before dropping it"} { + if !contains(got[0].Explanation, want) { + t.Errorf("Explanation = %q, want it to contain %q", got[0].Explanation, want) + } + } + }) + + t.Run("mentions checking connectivity when active", func(t *testing.T) { + got := DetectReplicationSlotWarnings([]ReplicationSlotInfo{ + {SlotName: "live_replica_slot", Active: true, RetainedBytes: 2 * ReplicationSlotWarningBytes}, + }) + + if len(got) != 1 { + t.Fatalf("expected exactly 1 warning, got %d", len(got)) + } + for _, want := range []string{"live_replica_slot", "connectivity and load"} { + 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/sequence_overflow_test.go b/go/internal/domain/sequence_overflow_test.go new file mode 100644 index 0000000..ea42acd --- /dev/null +++ b/go/internal/domain/sequence_overflow_test.go @@ -0,0 +1,97 @@ +package domain + +import "testing" + +func TestDetectSequenceOverflowRisks(t *testing.T) { + tests := []struct { + name string + sequences []SequenceUsage + want []string // sequence names expected in the result, in order + }{ + { + name: "max value zero or negative is ignored to avoid division by zero", + sequences: []SequenceUsage{ + {Sequence: "broken_seq", CurrentValue: 100, MaxValue: 0}, + }, + want: nil, + }, + { + name: "below the warning threshold is ignored", + sequences: []SequenceUsage{ + {Sequence: "healthy_seq", CurrentValue: 50, MaxValue: 100}, // 50% + }, + want: nil, + }, + { + name: "exactly at the warning threshold qualifies", + sequences: []SequenceUsage{ + {Sequence: "at_threshold_seq", CurrentValue: 75, MaxValue: 100}, // exactly 75% + }, + want: []string{"at_threshold_seq"}, + }, + { + name: "above the warning threshold qualifies", + sequences: []SequenceUsage{ + {Sequence: "near_max_seq", CurrentValue: 95, MaxValue: 100}, // 95% + }, + want: []string{"near_max_seq"}, + }, + { + name: "fully exhausted qualifies", + sequences: []SequenceUsage{ + {Sequence: "exhausted_seq", CurrentValue: 100, MaxValue: 100}, // 100% + }, + want: []string{"exhausted_seq"}, + }, + { + name: "mixed input returns only the qualifying sequences, preserving input order", + sequences: []SequenceUsage{ + {Sequence: "fine_seq", CurrentValue: 10, MaxValue: 100}, + {Sequence: "risky_one", CurrentValue: 80, MaxValue: 100}, + {Sequence: "also_fine", CurrentValue: 50, MaxValue: 100}, + {Sequence: "risky_two", CurrentValue: 90, MaxValue: 100}, + }, + want: []string{"risky_one", "risky_two"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectSequenceOverflowRisks(tt.sequences) + + if len(got) != len(tt.want) { + t.Fatalf("DetectSequenceOverflowRisks() returned %d results, want %d (%+v)", len(got), len(tt.want), got) + } + for i, name := range tt.want { + if got[i].Sequence != name { + t.Errorf("result[%d].Sequence = %q, want %q", i, got[i].Sequence, name) + } + } + }) + } +} + +func TestDetectSequenceOverflowRisks_NeverReturnsNilSlice(t *testing.T) { + got := DetectSequenceOverflowRisks(nil) + if got == nil { + t.Fatal("DetectSequenceOverflowRisks(nil) returned a nil slice, want an empty non-nil slice") + } + if len(got) != 0 { + t.Fatalf("DetectSequenceOverflowRisks(nil) = %v, want empty", got) + } +} + +func TestDetectSequenceOverflowRisks_Explanation(t *testing.T) { + got := DetectSequenceOverflowRisks([]SequenceUsage{ + {Sequence: "orders_id_seq", CurrentValue: 90, MaxValue: 100}, + }) + + if len(got) != 1 { + t.Fatalf("expected exactly 1 result, got %d", len(got)) + } + for _, want := range []string{"orders_id_seq", "90%", "90 of 100", "bigint"} { + 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/session_json.go b/go/internal/domain/session_json.go index d782d44..0934eb8 100644 --- a/go/internal/domain/session_json.go +++ b/go/internal/domain/session_json.go @@ -41,3 +41,26 @@ func (s Session) MarshalJSON() ([]byte, error) { Locks: s.Locks, }) } +func (s *Session) UnmarshalJSON(data []byte) error { + var wire sessionJSON + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + + *s = Session{ + ID: wire.ID, + User: wire.User, + ApplicationName: wire.ApplicationName, + ClientAddress: wire.ClientAddress, + State: wire.State, + WaitEventType: wire.WaitEventType, + WaitEvent: wire.WaitEvent, + Query: wire.Query, + Operation: wire.Operation, + QueryStarted: wire.QueryStarted, + Duration: time.Duration(wire.DurationSeconds * float64(time.Second)), + BlockedBy: wire.BlockedBy, + Locks: wire.Locks, + } + return nil +} diff --git a/go/internal/domain/session_test.go b/go/internal/domain/session_test.go new file mode 100644 index 0000000..dacb8bc --- /dev/null +++ b/go/internal/domain/session_test.go @@ -0,0 +1,116 @@ +package domain + +import ( + "encoding/json" + "testing" + "time" +) + +func TestSession_IsBlocked(t *testing.T) { + tests := []struct { + name string + session Session + want bool + }{ + { + name: "no blockers is not blocked", + session: Session{BlockedBy: nil}, + want: false, + }, + { + name: "empty blocker slice is not blocked", + session: Session{BlockedBy: []string{}}, + want: false, + }, + { + name: "one blocker is blocked", + session: Session{BlockedBy: []string{"123"}}, + want: true, + }, + { + name: "multiple blockers is blocked", + session: Session{BlockedBy: []string{"123", "456"}}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.session.IsBlocked(); got != tt.want { + t.Errorf("IsBlocked() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSession_MarshalJSON(t *testing.T) { + started := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + session := Session{ + ID: "42", + User: "app", + ApplicationName: "web", + ClientAddress: "10.0.0.5", + State: SessionStateActive, + WaitEventType: WaitEventTypeLock, + WaitEvent: "transactionid", + Query: "SELECT 1", + Operation: QueryOperationSelect, + QueryStarted: started, + Duration: 2500 * time.Millisecond, + BlockedBy: []string{"7"}, + Locks: []LockedObject{{NativeMode: "AccessExclusiveLock", Severity: LockSeverityExclusive, Granted: true}}, + } + + raw, err := json.Marshal(session) + if err != nil { + t.Fatalf("MarshalJSON() error = %v", err) + } + + var decoded map[string]interface{} + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("failed to unmarshal produced JSON: %v", err) + } + + if _, exists := decoded["duration"]; exists { + t.Error(`decoded JSON has a raw "duration" field, want it replaced by "durationSeconds"`) + } + + gotDurationSeconds, ok := decoded["durationSeconds"].(float64) + if !ok { + t.Fatalf("durationSeconds is missing or not a number: %v", decoded["durationSeconds"]) + } + if gotDurationSeconds != 2.5 { + t.Errorf("durationSeconds = %v, want 2.5", gotDurationSeconds) + } + + if decoded["id"] != "42" { + t.Errorf("id = %v, want %q", decoded["id"], "42") + } + if decoded["state"] != string(SessionStateActive) { + t.Errorf("state = %v, want %q", decoded["state"], SessionStateActive) + } + if decoded["operation"] != string(QueryOperationSelect) { + t.Errorf("operation = %v, want %q", decoded["operation"], QueryOperationSelect) + } + + blockedBy, ok := decoded["blockedBy"].([]interface{}) + if !ok || len(blockedBy) != 1 || blockedBy[0] != "7" { + t.Errorf("blockedBy = %v, want [\"7\"]", decoded["blockedBy"]) + } +} + +func TestSession_MarshalJSON_ZeroDuration(t *testing.T) { + raw, err := json.Marshal(Session{ID: "1", Duration: 0}) + if err != nil { + t.Fatalf("MarshalJSON() error = %v", err) + } + + var decoded map[string]interface{} + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("failed to unmarshal produced JSON: %v", err) + } + + if decoded["durationSeconds"] != float64(0) { + t.Errorf("durationSeconds = %v, want 0", decoded["durationSeconds"]) + } +} diff --git a/go/internal/domain/unlogged_table.go b/go/internal/domain/unlogged_table.go new file mode 100644 index 0000000..8eede9c --- /dev/null +++ b/go/internal/domain/unlogged_table.go @@ -0,0 +1,24 @@ +package domain + +import "fmt" + +// UnloggedTable is a table created with UNLOGGED — Postgres skips +// write-ahead logging for it, which makes writes faster but means the +// table is not crash-safe: it's silently truncated after an unclean +// shutdown (a crash, an unexpected power loss, ...). Existing simply +// because the table is unlogged is the finding here — there's no threshold +// to cross, just a fact worth surfacing. +type UnloggedTable struct { + Table string `json:"table"` + Explanation string `json:"explanation"` +} + +func NewUnloggedTable(table string) UnloggedTable { + return UnloggedTable{ + Table: table, + Explanation: fmt.Sprintf( + "Table %q is UNLOGGED. Writes to it skip the write-ahead log, which makes them faster, but the table is silently truncated (all rows lost) after an unclean shutdown (a crash, power loss, or `pg_ctl stop -m immediate`). Confirm this is intentional — if this table holds anything you'd need after a crash, consider making it a regular (logged) table instead.", + table, + ), + } +} diff --git a/go/internal/domain/unused_index_test.go b/go/internal/domain/unused_index_test.go new file mode 100644 index 0000000..d329f41 --- /dev/null +++ b/go/internal/domain/unused_index_test.go @@ -0,0 +1,133 @@ +package domain + +import "testing" + +func TestDetectUnusedIndexes(t *testing.T) { + tests := []struct { + name string + indexes []UnusedIndexInfo + statsAgeSeconds float64 + want []string // index names expected in the result, in order + }{ + { + name: "stats age below the minimum observation window returns nothing, regardless of scan count", + indexes: []UnusedIndexInfo{ + {Table: "orders", Index: "idx_never_used", IndexScans: 0}, + }, + statsAgeSeconds: MinStatsAgeSecondsForUnused - 1, + want: nil, + }, + { + name: "stats age exactly at the minimum observation window is accepted", + indexes: []UnusedIndexInfo{ + {Table: "orders", Index: "idx_never_used", IndexScans: 0}, + }, + statsAgeSeconds: MinStatsAgeSecondsForUnused, + want: []string{"idx_never_used"}, + }, + { + name: "index scans above the max-scans floor is not flagged", + indexes: []UnusedIndexInfo{ + {Table: "orders", Index: "idx_used_often", IndexScans: 51}, + }, + statsAgeSeconds: MinStatsAgeSecondsForUnused, + want: nil, + }, + { + name: "index scans exactly at the max-scans floor is still flagged", + indexes: []UnusedIndexInfo{ + {Table: "orders", Index: "idx_at_floor", IndexScans: MaxScansForUnused}, + }, + statsAgeSeconds: MinStatsAgeSecondsForUnused, + want: []string{"idx_at_floor"}, + }, + { + name: "zero scans over a long observation window is flagged", + indexes: []UnusedIndexInfo{ + {Table: "orders", Index: "idx_dead", IndexScans: 0}, + }, + statsAgeSeconds: MinStatsAgeSecondsForUnused * 10, + want: []string{"idx_dead"}, + }, + { + name: "mixed input returns only the qualifying indexes, preserving input order", + indexes: []UnusedIndexInfo{ + {Table: "orders", Index: "idx_busy", IndexScans: 10000}, + {Table: "orders", Index: "idx_quiet_one", IndexScans: 2}, + {Table: "orders", Index: "idx_moderate", IndexScans: 500}, + {Table: "orders", Index: "idx_quiet_two", IndexScans: 0}, + }, + statsAgeSeconds: MinStatsAgeSecondsForUnused, + want: []string{"idx_quiet_one", "idx_quiet_two"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectUnusedIndexes(tt.indexes, tt.statsAgeSeconds) + + if len(got) != len(tt.want) { + t.Fatalf("DetectUnusedIndexes() returned %d results, want %d (%+v)", len(got), len(tt.want), got) + } + for i, name := range tt.want { + if got[i].Index != name { + t.Errorf("result[%d].Index = %q, want %q", i, got[i].Index, name) + } + } + }) + } +} + +func TestDetectUnusedIndexes_NeverReturnsNilSlice(t *testing.T) { + got := DetectUnusedIndexes(nil, MinStatsAgeSecondsForUnused) + if got == nil { + t.Fatal("DetectUnusedIndexes(nil, ...) returned a nil slice, want an empty non-nil slice") + } + if len(got) != 0 { + t.Fatalf("DetectUnusedIndexes(nil, ...) = %v, want empty", got) + } + + gotTooYoung := DetectUnusedIndexes([]UnusedIndexInfo{{Table: "orders", Index: "idx_x"}}, 0) + if gotTooYoung == nil { + t.Fatal("DetectUnusedIndexes(..., 0) returned a nil slice, want an empty non-nil slice") + } +} + +func TestDetectUnusedIndexes_Explanation(t *testing.T) { + got := DetectUnusedIndexes([]UnusedIndexInfo{ + {Table: "orders", Index: "idx_stale", SizeBytes: 2048, IndexScans: 3}, + }, MinStatsAgeSecondsForUnused*2) // 2 hours + + if len(got) != 1 { + t.Fatalf("expected exactly 1 result, got %d", len(got)) + } + for _, want := range []string{"idx_stale", "orders", "3 time(s)", "2.0 hours", "2.0 KiB"} { + if !contains(got[0].Explanation, want) { + t.Errorf("Explanation = %q, want it to contain %q", got[0].Explanation, want) + } + } +} + +func TestFormatBytes(t *testing.T) { + tests := []struct { + name string + bytes int64 + want string + }{ + {name: "zero bytes", bytes: 0, want: "0 B"}, + {name: "below one KiB stays in bytes", bytes: 512, want: "512 B"}, + {name: "just below one KiB stays in bytes", bytes: 1023, want: "1023 B"}, + {name: "exactly one KiB", bytes: 1024, want: "1.0 KiB"}, + {name: "one and a half KiB", bytes: 1536, want: "1.5 KiB"}, + {name: "exactly one MiB", bytes: 1024 * 1024, want: "1.0 MiB"}, + {name: "exactly one GiB", bytes: 1024 * 1024 * 1024, want: "1.0 GiB"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatBytes(tt.bytes); got != tt.want { + t.Errorf("formatBytes(%d) = %q, want %q", tt.bytes, got, tt.want) + } + }) + } +} diff --git a/go/internal/domain/vacuum_health_test.go b/go/internal/domain/vacuum_health_test.go new file mode 100644 index 0000000..604b3c2 --- /dev/null +++ b/go/internal/domain/vacuum_health_test.go @@ -0,0 +1,177 @@ +package domain + +import ( + "strings" + "testing" + "time" +) + +func TestDetectVacuumHealthWarnings(t *testing.T) { + tests := []struct { + name string + stats []TableVacuumStats + want []string // table names expected in the result, in order + }{ + { + name: "table below the minimum live-tuple floor is ignored even with a high ratio", + stats: []TableVacuumStats{ + {Table: "tiny_table", LiveTuples: 10, DeadTuples: 900}, + }, + want: nil, + }, + { + name: "table at exactly the minimum live-tuple floor qualifies", + stats: []TableVacuumStats{ + {Table: "at_floor", LiveTuples: MinLiveTuplesForVacuumCheck, DeadTuples: 10000}, + }, + want: []string{"at_floor"}, + }, + { + name: "ratio below the warning threshold is ignored", + stats: []TableVacuumStats{ + {Table: "healthy_table", LiveTuples: 10000, DeadTuples: 100}, + }, + want: nil, + }, + { + name: "ratio exactly at the warning threshold qualifies", + stats: []TableVacuumStats{ + {Table: "at_threshold", LiveTuples: 8000, DeadTuples: 2000}, // 20% + }, + want: []string{"at_threshold"}, + }, + { + name: "ratio above the warning threshold qualifies", + stats: []TableVacuumStats{ + {Table: "bloated_table", LiveTuples: 6000, DeadTuples: 4000}, // 40% + }, + want: []string{"bloated_table"}, + }, + { + name: "table with zero rows is ignored, not treated as 100% dead", + stats: []TableVacuumStats{ + {Table: "empty_table", LiveTuples: 0, DeadTuples: 0}, + }, + want: nil, + }, + { + name: "mixed input returns only the qualifying tables, preserving input order", + stats: []TableVacuumStats{ + {Table: "fine", LiveTuples: 10000, DeadTuples: 100}, + {Table: "bloated_one", LiveTuples: 6000, DeadTuples: 4000}, + {Table: "too_small", LiveTuples: 50, DeadTuples: 900}, + {Table: "bloated_two", LiveTuples: 5000, DeadTuples: 5000}, + }, + want: []string{"bloated_one", "bloated_two"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectVacuumHealthWarnings(tt.stats) + + if len(got) != len(tt.want) { + t.Fatalf("DetectVacuumHealthWarnings() returned %d warnings, want %d (%v)", len(got), len(tt.want), got) + } + for i, w := range got { + if w.Table != tt.want[i] { + t.Errorf("warning[%d].Table = %q, want %q", i, w.Table, tt.want[i]) + } + } + }) + } +} + +func TestDetectVacuumHealthWarnings_NeverReturnsNilSlice(t *testing.T) { + got := DetectVacuumHealthWarnings(nil) + if got == nil { + t.Fatal("DetectVacuumHealthWarnings(nil) returned a nil slice, want an empty non-nil slice (callers may rely on this for JSON serialization as [])") + } + if len(got) != 0 { + t.Fatalf("DetectVacuumHealthWarnings(nil) = %v, want empty", got) + } +} + +func TestDetectVacuumHealthWarnings_Explanation(t *testing.T) { + t.Run("mentions it was never autovacuumed when LastAutovacuum is nil", func(t *testing.T) { + got := DetectVacuumHealthWarnings([]TableVacuumStats{ + {Table: "never_vacuumed", LiveTuples: 6000, DeadTuples: 4000}, + }) + + if len(got) != 1 { + t.Fatalf("expected exactly 1 warning, got %d", len(got)) + } + if !strings.Contains(got[0].Explanation, "never been autovacuumed") { + t.Errorf("Explanation = %q, want it to mention it was never autovacuumed", got[0].Explanation) + } + if got[0].LastAutovacuum != nil { + t.Errorf("LastAutovacuum = %v, want nil", got[0].LastAutovacuum) + } + }) + + t.Run("mentions the last autovacuum timestamp when present", func(t *testing.T) { + lastRun := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) + got := DetectVacuumHealthWarnings([]TableVacuumStats{ + {Table: "recently_vacuumed", LiveTuples: 6000, DeadTuples: 4000, LastAutovacuum: &lastRun}, + }) + + if len(got) != 1 { + t.Fatalf("expected exactly 1 warning, got %d", len(got)) + } + if !strings.Contains(got[0].Explanation, "Last autovacuumed at") { + t.Errorf("Explanation = %q, want it to mention the last autovacuum time", got[0].Explanation) + } + if got[0].LastAutovacuum == nil || !got[0].LastAutovacuum.Equal(lastRun) { + t.Errorf("LastAutovacuum = %v, want %v", got[0].LastAutovacuum, lastRun) + } + }) +} + +func TestTableVacuumStats_DeadTupleRatio(t *testing.T) { + tests := []struct { + name string + stats TableVacuumStats + want float64 + }{ + { + name: "no rows at all yields zero, not division by zero", + stats: TableVacuumStats{ + LiveTuples: 0, + DeadTuples: 0, + }, + want: 0, + }, + { + name: "all live rows yields zero percent dead", + stats: TableVacuumStats{ + LiveTuples: 1000, + DeadTuples: 0, + }, + want: 0, + }, + { + name: "all dead rows yields one hundred percent", + stats: TableVacuumStats{ + LiveTuples: 0, + DeadTuples: 1000, + }, + want: 100, + }, + { + name: "even split yields fifty percent", + stats: TableVacuumStats{ + LiveTuples: 500, + DeadTuples: 500, + }, + want: 50, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.stats.deadTupleRatio(); got != tt.want { + t.Errorf("deadTupleRatio() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/go/internal/infrastructure/config/config.go b/go/internal/infrastructure/config/config.go index 50524f6..8e56d61 100644 --- a/go/internal/infrastructure/config/config.go +++ b/go/internal/infrastructure/config/config.go @@ -10,18 +10,28 @@ import ( // Config holds all runtime configuration for pgscope, sourced from // environment variables. No defaults are silently assumed for secrets. type Config struct { - DatabaseURL string - HTTPPort string - PollInterval time.Duration - APIKey string - InsightsRateLimitPerSecond float64 - InsightsRateLimitBurst int + DatabaseURL string + HTTPPort string + PollInterval time.Duration + APIKey string + InsightsRateLimitPerSecond float64 + InsightsRateLimitBurst int + HistoryDBPath string + HistoryRetention time.Duration + HistoryRecordInterval time.Duration + HistoryMaxSessionsPerSnapshot int + HistoryMaxDBSizeBytes int64 } const ( - defaultPollIntervalSeconds = 1 - defaultInsightsRateLimitPerSecond = 5 - defaultInsightsRateLimitBurst = 20 + defaultPollIntervalSeconds = 1 + defaultInsightsRateLimitPerSecond = 5 + defaultInsightsRateLimitBurst = 20 + defaultHistoryDBPath = "./data/pgscope.db" + defaultHistoryRetentionDays = 3 + defaultHistoryRecordIntervalSeconds = 15 + defaultHistoryMaxSessionsPerSnapshot = 50 + defaultHistoryMaxDBSizeMB = 500 ) func Load() (Config, error) { @@ -55,13 +65,43 @@ func Load() (Config, error) { return Config{}, err } + historyDBPath := os.Getenv("PGSCOPE_HISTORY_DB_PATH") + if historyDBPath == "" { + historyDBPath = defaultHistoryDBPath + } + + historyRetention, err := loadHistoryRetention() + if err != nil { + return Config{}, err + } + + historyRecordInterval, err := loadHistoryRecordInterval() + if err != nil { + return Config{}, err + } + + historyMaxSessions, err := loadHistoryMaxSessionsPerSnapshot() + if err != nil { + return Config{}, err + } + + historyMaxDBSizeBytes, err := loadHistoryMaxDBSizeBytes() + if err != nil { + return Config{}, err + } + return Config{ - DatabaseURL: databaseURL, - HTTPPort: httpPort, - PollInterval: pollInterval, - APIKey: apiKey, - InsightsRateLimitPerSecond: insightsRateLimit, - InsightsRateLimitBurst: insightsBurst, + DatabaseURL: databaseURL, + HTTPPort: httpPort, + PollInterval: pollInterval, + APIKey: apiKey, + InsightsRateLimitPerSecond: insightsRateLimit, + InsightsRateLimitBurst: insightsBurst, + HistoryDBPath: historyDBPath, + HistoryRetention: historyRetention, + HistoryRecordInterval: historyRecordInterval, + HistoryMaxSessionsPerSnapshot: historyMaxSessions, + HistoryMaxDBSizeBytes: historyMaxDBSizeBytes, }, nil } @@ -111,3 +151,78 @@ func loadInsightsRateLimitBurst() (int, error) { return burst, nil } + +// loadHistoryRetention controls how long periodic snapshots are kept in the +// SQLite history store before being pruned. Incident snapshots (a blocking +// chain appearing, etc.) are never pruned by age regardless of this value, +// since they're rare and stay valuable far longer than routine samples. +func loadHistoryRetention() (time.Duration, error) { + raw := os.Getenv("PGSCOPE_HISTORY_RETENTION_DAYS") + if raw == "" { + return defaultHistoryRetentionDays * 24 * time.Hour, nil + } + + days, err := strconv.Atoi(raw) + if err != nil || days <= 0 { + return 0, fmt.Errorf("PGSCOPE_HISTORY_RETENTION_DAYS must be a positive integer, got %q", raw) + } + + return time.Duration(days) * 24 * time.Hour, nil +} + +// loadHistoryRecordInterval controls how often a periodic snapshot is +// persisted to the history store, decoupled from PollInterval (which +// governs the live SSE dashboard). A slower record cadence keeps disk +// usage bounded independent of poll frequency; incident snapshots are +// still recorded immediately regardless of this interval. +func loadHistoryRecordInterval() (time.Duration, error) { + raw := os.Getenv("PGSCOPE_HISTORY_RECORD_INTERVAL_SECONDS") + if raw == "" { + return defaultHistoryRecordIntervalSeconds * time.Second, nil + } + + seconds, err := strconv.Atoi(raw) + if err != nil || seconds <= 0 { + return 0, fmt.Errorf("PGSCOPE_HISTORY_RECORD_INTERVAL_SECONDS must be a positive integer, got %q", raw) + } + + return time.Duration(seconds) * time.Second, nil +} + +// loadHistoryMaxSessionsPerSnapshot bounds how many sessions a periodic +// snapshot stores, regardless of how many are actually active — this keeps +// per-snapshot size (and therefore disk growth) independent of how busy the +// monitored database is. Incident snapshots are never trimmed this way, +// since understanding a blocking chain requires every session involved. +func loadHistoryMaxSessionsPerSnapshot() (int, error) { + raw := os.Getenv("PGSCOPE_HISTORY_MAX_SESSIONS_PER_SNAPSHOT") + if raw == "" { + return defaultHistoryMaxSessionsPerSnapshot, nil + } + + maxSessions, err := strconv.Atoi(raw) + if err != nil || maxSessions <= 0 { + return 0, fmt.Errorf("PGSCOPE_HISTORY_MAX_SESSIONS_PER_SNAPSHOT must be a positive integer, got %q", raw) + } + + return maxSessions, nil +} + +// loadHistoryMaxDBSizeBytes is a backstop on top of the age-based retention +// (loadHistoryRetention) — if the history file somehow grows past this size +// before the retention window catches up (an unusually busy monitored +// database, a misconfigured record interval, ...), older periodic snapshots +// are pruned more aggressively to bring it back under the cap. +func loadHistoryMaxDBSizeBytes() (int64, error) { + raw := os.Getenv("PGSCOPE_HISTORY_MAX_DB_SIZE_MB") + if raw == "" { + return defaultHistoryMaxDBSizeMB * 1024 * 1024, nil + } + + megabytes, err := strconv.ParseInt(raw, 10, 64) + if err != nil || megabytes <= 0 { + return 0, fmt.Errorf("PGSCOPE_HISTORY_MAX_DB_SIZE_MB must be a positive integer, got %q", raw) + } + + return megabytes * 1024 * 1024, nil +} diff --git a/go/internal/infrastructure/history/redact.go b/go/internal/infrastructure/history/redact.go new file mode 100644 index 0000000..9dac45b --- /dev/null +++ b/go/internal/infrastructure/history/redact.go @@ -0,0 +1,199 @@ +package history + +// redactQueryLiterals replaces string and numeric literals in a raw SQL +// query with "***" before it's persisted to the history store. pg_stat_ +// activity reports queries with their actual bound values, which can +// include sensitive data (emails, tokens, etc.) — this is a best-effort +// defense-in-depth measure, not a guarantee of complete redaction. +// +// Handles: plain '...' strings (including doubled ” escapes), E'...' +// escape strings, X'...' hex strings, B'...' bit strings, and $$...$$ / +// $tag$...$tag$ dollar-quoted strings (common in function bodies and DO +// blocks). Parameter placeholders ($1, $2, ...) are left untouched, since +// they never carry a literal value themselves. +func redactQueryLiterals(query string) string { + runes := []rune(query) + var b []rune + i := 0 + + for i < len(runes) { + c := runes[i] + + if c == '$' { + if end, isPlaceholder := skipPlaceholder(runes, i); isPlaceholder { + b = append(b, runes[i:end]...) + i = end + continue + } + if end, matched := skipDollarQuote(runes, i); matched { + b = append(b, []rune("***")...) + i = end + continue + } + b = append(b, c) + i++ + continue + } + + if isStringPrefix(c) && (i == 0 || !isIdentChar(runes[i-1])) && i+1 < len(runes) && runes[i+1] == '\'' { + b = append(b, []rune("***")...) + i = skipSingleQuoteLiteral(runes, i+1) + continue + } + + if c == '\'' { + b = append(b, []rune("***")...) + i = skipSingleQuoteLiteral(runes, i) + continue + } + + if isDigit(c) && (i == 0 || !isIdentChar(runes[i-1])) { + j := scanNumericLiteral(runes, i) + b = append(b, []rune("***")...) + i = j + continue + } + + b = append(b, c) + i++ + } + + return string(b) +} + +// skipSingleQuoteLiteral takes the index of an opening ' and returns the +// index just past its matching closing ', treating ” as an escaped quote +// rather than a terminator. +func skipSingleQuoteLiteral(runes []rune, quoteIndex int) int { + i := quoteIndex + 1 + for i < len(runes) { + if runes[i] == '\'' { + if i+1 < len(runes) && runes[i+1] == '\'' { + i += 2 + continue + } + return i + 1 + } + i++ + } + return i +} + +// skipPlaceholder recognizes a parameter placeholder like $1 or $42 — these +// never carry a literal value, so they're passed through unredacted. +func skipPlaceholder(runes []rune, dollarIndex int) (end int, matched bool) { + j := dollarIndex + 1 + if j >= len(runes) || !isDigit(runes[j]) { + return dollarIndex, false + } + for j < len(runes) && isDigit(runes[j]) { + j++ + } + return j, true +} + +// skipDollarQuote recognizes a dollar-quoted string starting at dollarIndex +// ($$...$$ or $tag$...$tag$, as used in function bodies and DO blocks) and +// returns the index just past its closing delimiter. If the opening +// delimiter isn't well-formed, matched is false. If no closing delimiter is +// found, the whole remainder is treated as the literal — safer to +// over-redact than under-redact. +func skipDollarQuote(runes []rune, dollarIndex int) (end int, matched bool) { + j := dollarIndex + 1 + for j < len(runes) && runes[j] != '$' && isIdentChar(runes[j]) { + j++ + } + if j >= len(runes) || runes[j] != '$' { + return dollarIndex, false + } + + delimiter := runes[dollarIndex : j+1] + contentStart := j + 1 + + closeIdx := indexOfRuneSeq(runes, contentStart, delimiter) + if closeIdx == -1 { + return len(runes), true + } + return closeIdx + len(delimiter), true +} + +func indexOfRuneSeq(runes []rune, from int, seq []rune) int { + for k := from; k+len(seq) <= len(runes); k++ { + match := true + for m := 0; m < len(seq); m++ { + if runes[k+m] != seq[m] { + match = false + break + } + } + if match { + return k + } + } + return -1 +} + +func isStringPrefix(r rune) bool { + switch r { + case 'e', 'E', 'x', 'X', 'b', 'B': + return true + } + return false +} + +// scanNumericLiteral returns the index just past a numeric literal starting +// at start (which must be a digit). Handles PostgreSQL 16's non-decimal +// integer literals (0x1F, 0o17, 0b1010 — hex/octal/binary), underscore +// digit separators (1_000_000), and scientific notation (1e10, 1.5e-10) — +// not just plain decimal digits, so none of those leak past the initial +// masked character. +func scanNumericLiteral(runes []rune, start int) int { + j := start + + if runes[j] == '0' && j+1 < len(runes) && isRadixPrefix(runes[j+1]) { + j += 2 + for j < len(runes) && (isHexDigit(runes[j]) || runes[j] == '_') { + j++ + } + return j + } + + for j < len(runes) && (isDigit(runes[j]) || runes[j] == '.' || runes[j] == '_') { + j++ + } + + if j < len(runes) && (runes[j] == 'e' || runes[j] == 'E') { + k := j + 1 + if k < len(runes) && (runes[k] == '+' || runes[k] == '-') { + k++ + } + if k < len(runes) && isDigit(runes[k]) { + for k < len(runes) && isDigit(runes[k]) { + k++ + } + j = k + } + } + + return j +} + +func isRadixPrefix(r rune) bool { + switch r { + case 'x', 'X', 'o', 'O', 'b', 'B': + return true + } + return false +} + +func isHexDigit(r rune) bool { + return isDigit(r) || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') +} + +func isDigit(r rune) bool { + return r >= '0' && r <= '9' +} + +func isIdentChar(r rune) bool { + return r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || isDigit(r) +} diff --git a/go/internal/infrastructure/history/redact_test.go b/go/internal/infrastructure/history/redact_test.go new file mode 100644 index 0000000..d7e41dc --- /dev/null +++ b/go/internal/infrastructure/history/redact_test.go @@ -0,0 +1,155 @@ +package history + +import "testing" + +func TestRedactQueryLiterals(t *testing.T) { + tests := []struct { + name string + query string + want string + }{ + { + name: "no literals passes through unchanged", + query: "SELECT * FROM orders WHERE status = status", + want: "SELECT * FROM orders WHERE status = status", + }, + { + name: "a simple string literal is masked", + query: "SELECT * FROM users WHERE email = 'ali@example.com'", + want: "SELECT * FROM users WHERE email = ***", + }, + { + name: "a standalone numeric literal is masked", + query: "SELECT * FROM orders WHERE id = 12345", + want: "SELECT * FROM orders WHERE id = ***", + }, + { + name: "a decimal numeric literal is masked as one token", + query: "SELECT * FROM payments WHERE amount = 199.99", + want: "SELECT * FROM payments WHERE amount = ***", + }, + { + name: "multiple literals in the same query are all masked", + query: "SELECT * FROM orders WHERE user_id = 42 AND status = 'pending'", + want: "SELECT * FROM orders WHERE user_id = *** AND status = ***", + }, + { + name: "an escaped single quote inside a string literal does not truncate the mask", + query: "SELECT * FROM users WHERE name = 'O''Brien'", + want: "SELECT * FROM users WHERE name = ***", + }, + { + name: "digits that are part of an identifier are left alone", + query: "SELECT column1, table2 FROM schema3.orders", + want: "SELECT column1, table2 FROM schema3.orders", + }, + { + name: "an empty string literal is still masked", + query: "SELECT * FROM users WHERE middle_name = ''", + want: "SELECT * FROM users WHERE middle_name = ***", + }, + { + name: "empty query stays empty", + query: "", + want: "", + }, + { + name: "a query with only a numeric literal is fully masked", + query: "123", + want: "***", + }, + { + name: "a hexadecimal integer literal is masked in full", + query: "SELECT * FROM flags WHERE mask = 0x1F", + want: "SELECT * FROM flags WHERE mask = ***", + }, + { + name: "a binary integer literal is masked in full", + query: "SELECT * FROM flags WHERE mask = 0b101010", + want: "SELECT * FROM flags WHERE mask = ***", + }, + { + name: "an octal integer literal is masked in full", + query: "SELECT * FROM flags WHERE mask = 0o17", + want: "SELECT * FROM flags WHERE mask = ***", + }, + { + name: "an underscore-separated numeric literal is masked in full", + query: "SELECT * FROM accounts WHERE balance = 1_000_000", + want: "SELECT * FROM accounts WHERE balance = ***", + }, + { + name: "scientific notation with a positive implicit exponent is masked in full", + query: "SELECT * FROM measurements WHERE value = 1e10", + want: "SELECT * FROM measurements WHERE value = ***", + }, + { + name: "scientific notation with a negative exponent is masked in full", + query: "SELECT * FROM measurements WHERE value = 1.5e-10", + want: "SELECT * FROM measurements WHERE value = ***", + }, + { + name: "scientific notation with an explicit positive exponent sign is masked in full", + query: "SELECT * FROM measurements WHERE value = 2E+5", + want: "SELECT * FROM measurements WHERE value = ***", + }, + { + name: "a lone 'e' immediately after a number without digits is not treated as an exponent", + query: "SELECT * FROM t WHERE x = 5e", + want: "SELECT * FROM t WHERE x = ***e", + }, + { + name: "an E-string is masked", + query: "SELECT * FROM logs WHERE message = E'line1\\nline2'", + want: "SELECT * FROM logs WHERE message = ***", + }, + { + name: "a hex literal is masked", + query: "SELECT * FROM files WHERE checksum = X'DEADBEEF'", + want: "SELECT * FROM files WHERE checksum = ***", + }, + { + name: "a bit literal is masked", + query: "SELECT * FROM flags WHERE mask = B'1010'", + want: "SELECT * FROM flags WHERE mask = ***", + }, + { + name: "an empty dollar-quoted string is masked", + query: "DO $$ SELECT 1 $$", + want: "DO ***", + }, + { + name: "a tagged dollar-quoted string is masked", + query: "CREATE FUNCTION f() RETURNS void AS $body$ SELECT 'secret@example.com'; $body$ LANGUAGE sql", + want: "CREATE FUNCTION f() RETURNS void AS *** LANGUAGE sql", + }, + { + name: "an unterminated dollar-quote redacts to the end of the string rather than leaking content", + query: "DO $$ SELECT 'oops, no closing tag", + want: "DO ***", + }, + { + name: "a single-digit parameter placeholder is left untouched", + query: "SELECT * FROM users WHERE id = $1", + want: "SELECT * FROM users WHERE id = $1", + }, + { + name: "a multi-digit parameter placeholder is left untouched", + query: "SELECT * FROM users WHERE id = $1 AND org_id = $42", + want: "SELECT * FROM users WHERE id = $1 AND org_id = $42", + }, + { + name: "an ordinary identifier prefixed with e/x/b is not mistaken for a string prefix", + query: "SELECT extra, x_value, b_flag FROM widgets", + want: "SELECT extra, x_value, b_flag FROM widgets", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := redactQueryLiterals(tt.query); got != tt.want { + t.Errorf("redactQueryLiterals(%q) = %q, want %q", tt.query, got, tt.want) + } + }) + } +} diff --git a/go/internal/infrastructure/history/ring_buffer_store.go b/go/internal/infrastructure/history/ring_buffer_store.go index 72c94dd..cbc4af4 100644 --- a/go/internal/infrastructure/history/ring_buffer_store.go +++ b/go/internal/infrastructure/history/ring_buffer_store.go @@ -3,6 +3,7 @@ package history import ( "context" "sync" + "time" "github.com/fayupable/pgscope/internal/domain" ) @@ -42,11 +43,20 @@ func (s *RingBufferStore) Append(_ context.Context, snapshot domain.Snapshot) er return nil } -func (s *RingBufferStore) Recent(_ context.Context) ([]domain.Snapshot, error) { +// Recent ignores downsampling entirely — the ring buffer's fixed ~5-minute +// window is already small enough that no window ever needs thinning. since +// simply filters out anything captured before it. +func (s *RingBufferStore) Recent(_ context.Context, since time.Time) ([]domain.Snapshot, error) { s.mu.RLock() defer s.mu.RUnlock() - return copySnapshots(s.periodic), nil + result := make([]domain.Snapshot, 0, len(s.periodic)) + for _, snap := range s.periodic { + if !snap.CapturedAt.Before(since) { + result = append(result, snap) + } + } + return result, nil } func (s *RingBufferStore) Incidents(_ context.Context) ([]domain.Snapshot, error) { diff --git a/go/internal/infrastructure/history/sqlite_store.go b/go/internal/infrastructure/history/sqlite_store.go new file mode 100644 index 0000000..9944cac --- /dev/null +++ b/go/internal/infrastructure/history/sqlite_store.go @@ -0,0 +1,128 @@ +package history + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" + + "github.com/fayupable/pgscope/internal/domain" +) + +// SQLiteStore implements output.IHistoryStorePort backed by a single local +// SQLite file. Unlike RingBufferStore, history survives a process restart. +// Query text is redacted (redactQueryLiterals) before it's ever written to +// disk, since pg_stat_activity reports queries with their actual bound +// values, which may include sensitive data. +// +// This type's methods are split across three files by responsibility: +// - sqlite_store.go (this file): setup and writes (NewSQLiteStore, Close, +// Append) +// - sqlite_store_query.go: reads (Recent, Incidents, downsampling) +// - sqlite_store_maintenance.go: pruning and disk-size enforcement +type SQLiteStore struct { + db *sql.DB + path string +} + +// NewSQLiteStore opens (creating if necessary) the SQLite file at path and +// ensures its schema exists. SQLite does not handle concurrent writers +// well, so the connection pool is capped at one connection — acceptable +// at this project's write volume (one snapshot per poll interval). +func NewSQLiteStore(path string) (*SQLiteStore, error) { + if dir := filepath.Dir(path); dir != "." { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create history db directory: %w", err) + } + } + + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open history db: %w", err) + } + db.SetMaxOpenConns(1) + + // SQLite doesn't shrink the file on DELETE by default — deleted rows + // just become free pages reused later, so the file's on-disk size never + // drops on its own. Incremental auto-vacuum (only takes full effect on + // a database with no tables yet, i.e. a fresh file) lets EnforceMaxSize + // actually reclaim that space via PRAGMA incremental_vacuum after a + // prune, instead of only ever freeing pages internally. + // + // This MUST run before journal_mode=WAL below — empirically verified + // that setting journal_mode first silently prevents auto_vacuum from + // ever taking effect (auto_vacuum stays reported as NONE regardless), + // even though the database still has no tables at that point. Swapping + // the order fixes it; this isn't documented anywhere obvious, so don't + // reorder these two without re-verifying. + if _, err := db.Exec(`PRAGMA auto_vacuum=INCREMENTAL`); err != nil { + return nil, fmt.Errorf("set auto_vacuum mode: %w", err) + } + + if _, err := db.Exec(`PRAGMA journal_mode=WAL`); err != nil { + return nil, fmt.Errorf("enable WAL mode: %w", err) + } + + // A single connection serializes every read and write; without a busy + // timeout, an Append racing a long-running prune batch would fail + // immediately with "database is locked" instead of just waiting its turn. + if _, err := db.Exec(`PRAGMA busy_timeout=5000`); err != nil { + return nil, fmt.Errorf("set busy timeout: %w", err) + } + + if _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + captured_at TIMESTAMP NOT NULL, + trigger TEXT NOT NULL, + payload TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_snapshots_trigger_captured ON snapshots(trigger, captured_at); + `); err != nil { + return nil, fmt.Errorf("create history schema: %w", err) + } + + return &SQLiteStore{db: db, path: path}, nil +} + +func (s *SQLiteStore) Close() error { + return s.db.Close() +} + +func (s *SQLiteStore) Append(ctx context.Context, snapshot domain.Snapshot) error { + redacted := redactSnapshot(snapshot) + + payload, err := json.Marshal(redacted) + if err != nil { + return fmt.Errorf("marshal snapshot: %w", err) + } + + _, err = s.db.ExecContext(ctx, + `INSERT INTO snapshots (captured_at, trigger, payload) VALUES (?, ?, ?)`, + redacted.CapturedAt, string(redacted.Trigger), string(payload), + ) + if err != nil { + return fmt.Errorf("insert snapshot: %w", err) + } + return nil +} + +// redactSnapshot returns a copy of snapshot with every session's Query +// field redacted, leaving the original (used live, in memory) untouched. +func redactSnapshot(snapshot domain.Snapshot) domain.Snapshot { + redactedSessions := make([]domain.Session, len(snapshot.Sessions)) + for i, session := range snapshot.Sessions { + session.Query = redactQueryLiterals(session.Query) + redactedSessions[i] = session + } + + return domain.Snapshot{ + Sessions: redactedSessions, + CapturedAt: snapshot.CapturedAt, + Trigger: snapshot.Trigger, + } +} diff --git a/go/internal/infrastructure/history/sqlite_store_maintenance.go b/go/internal/infrastructure/history/sqlite_store_maintenance.go new file mode 100644 index 0000000..72d2e63 --- /dev/null +++ b/go/internal/infrastructure/history/sqlite_store_maintenance.go @@ -0,0 +1,211 @@ +package history + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/fayupable/pgscope/internal/domain" +) + +const ( + pruneBatchSize = 500 + pruneBatchPause = 50 * time.Millisecond + + // MaxIncidentRows is the recommended cap for PruneExcessIncidents — + // incidents are never pruned by age, so callers should apply a count + // cap periodically to bound the history file's growth. + MaxIncidentRows = 10000 +) + +// PruneOlderThan deletes periodic snapshots captured before the given time, +// in small batches so a large backlog never locks the database for long or +// causes a CPU/memory spike. Incident snapshots are never pruned this way — +// they're rare and valuable enough to keep regardless of age. +func (s *SQLiteStore) PruneOlderThan(ctx context.Context, before time.Time) error { + for { + result, err := s.db.ExecContext(ctx, + `DELETE FROM snapshots WHERE id IN ( + SELECT id FROM snapshots + WHERE captured_at < ? AND trigger = ? + LIMIT ? + )`, + before, string(domain.SnapshotTriggerPeriodic), pruneBatchSize, + ) + if err != nil { + return fmt.Errorf("prune old snapshots: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read prune batch result: %w", err) + } + if rowsAffected == 0 { + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pruneBatchPause): + } + } +} + +// PruneExcessIncidents keeps only the most recent maxRows incident +// snapshots, deleting the rest in the same small-batch style as +// PruneOlderThan. Unlike periodic snapshots, incidents are never pruned by +// age — this is a count-based safety net so a database with a persistently +// noisy blocking pattern can't grow the history file without bound. +func (s *SQLiteStore) PruneExcessIncidents(ctx context.Context, maxRows int) error { + var count int + if err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM snapshots WHERE trigger = ?`, + string(domain.SnapshotTriggerIncident), + ).Scan(&count); err != nil { + return fmt.Errorf("count incident snapshots: %w", err) + } + if count <= maxRows { + return nil + } + + var cutoff time.Time + if err := s.db.QueryRowContext(ctx, + `SELECT captured_at FROM snapshots + WHERE trigger = ? + ORDER BY captured_at DESC + LIMIT 1 OFFSET ?`, + string(domain.SnapshotTriggerIncident), maxRows-1, + ).Scan(&cutoff); err != nil { + return fmt.Errorf("find incident prune cutoff: %w", err) + } + + for { + result, err := s.db.ExecContext(ctx, + `DELETE FROM snapshots WHERE id IN ( + SELECT id FROM snapshots + WHERE captured_at < ? AND trigger = ? + LIMIT ? + )`, + cutoff, string(domain.SnapshotTriggerIncident), pruneBatchSize, + ) + if err != nil { + return fmt.Errorf("prune excess incident snapshots: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read incident prune batch result: %w", err) + } + if rowsAffected == 0 { + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pruneBatchPause): + } + } +} + +// EnforceMaxSize is a backstop on top of age-based retention +// (PruneOlderThan) — if the history file's on-disk size exceeds maxBytes +// (an unusually busy monitored database, a misconfigured record interval, +// ...), the oldest periodic snapshots are deleted first. If that alone +// isn't enough to get back under the cap (periodic snapshots exhausted but +// the file is still too large — only realistic during a sustained, severe +// blocking storm that outpaces PruneExcessIncidents' count cap), the +// oldest incidents are pruned too. The disk-size guarantee holds +// regardless of trigger type; PruneExcessIncidents remains the first line +// of defense for incident growth under normal conditions. +func (s *SQLiteStore) EnforceMaxSize(ctx context.Context, maxBytes int64) error { + size, err := s.fileSize() + if err != nil { + return fmt.Errorf("stat history db file: %w", err) + } + + size, err = s.pruneUntilUnderSize(ctx, maxBytes, size, domain.SnapshotTriggerPeriodic) + if err != nil { + return err + } + + if size > maxBytes { + if _, err := s.pruneUntilUnderSize(ctx, maxBytes, size, domain.SnapshotTriggerIncident); err != nil { + return err + } + } + + return nil +} + +// pruneUntilUnderSize deletes the oldest snapshots matching trigger, in +// small batches (with an incremental_vacuum after each, since SQLite +// otherwise never actually shrinks the file on disk), until the file is +// back under maxBytes or there's nothing left of that trigger type to +// delete — whichever comes first. Returns the file's resulting size either +// way, so the caller can decide whether a second pass (a different +// trigger) is needed. +func (s *SQLiteStore) pruneUntilUnderSize(ctx context.Context, maxBytes, currentSize int64, trigger domain.SnapshotTrigger) (int64, error) { + size := currentSize + + for size > maxBytes { + result, err := s.db.ExecContext(ctx, + `DELETE FROM snapshots WHERE id IN ( + SELECT id FROM snapshots + WHERE trigger = ? + ORDER BY captured_at ASC + LIMIT ? + )`, + string(trigger), pruneBatchSize, + ) + if err != nil { + return size, fmt.Errorf("prune %s snapshots to enforce max size: %w", trigger, err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return size, fmt.Errorf("read size-enforcement prune batch result: %w", err) + } + if rowsAffected == 0 { + return size, nil + } + + if _, err := s.db.ExecContext(ctx, `PRAGMA incremental_vacuum`); err != nil { + return size, fmt.Errorf("incremental vacuum: %w", err) + } + + select { + case <-ctx.Done(): + return size, ctx.Err() + case <-time.After(pruneBatchPause): + } + + size, err = s.fileSize() + if err != nil { + return size, fmt.Errorf("stat history db file: %w", err) + } + } + + return size, nil +} + +// fileSize reports the history file's true on-disk size. In WAL mode, +// recent writes (and the space freed by recent deletes/incremental_vacuum) +// can sit in the separate -wal sidecar file rather than the main file for a +// while — os.Stat on the main path alone would badly undercount actual +// usage until SQLite's own automatic checkpoint eventually catches up. A +// checkpoint is forced first so the main file's size always reflects +// reality at the moment this is called. +func (s *SQLiteStore) fileSize() (int64, error) { + if _, err := s.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil { + return 0, fmt.Errorf("checkpoint before measuring size: %w", err) + } + + info, err := os.Stat(s.path) + if err != nil { + return 0, err + } + return info.Size(), nil +} diff --git a/go/internal/infrastructure/history/sqlite_store_query.go b/go/internal/infrastructure/history/sqlite_store_query.go new file mode 100644 index 0000000..dba3ed4 --- /dev/null +++ b/go/internal/infrastructure/history/sqlite_store_query.go @@ -0,0 +1,140 @@ +package history + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/fayupable/pgscope/internal/domain" +) + +// maxPeriodicRowsPerWindow bounds how many periodic snapshots Recent ever +// returns for a single window, regardless of how wide the window is or how +// dense the underlying data is. This is sized for what a replay +// chart/scrubber can usefully render — far more points than this just +// overlap on screen — and keeps the response payload bounded. Windows with +// more raw data than this are evenly downsampled rather than truncated, so +// the whole window stays represented instead of only its tail. +const maxPeriodicRowsPerWindow = 1500 + +// Recent returns every incident snapshot in the window untouched, plus +// periodic snapshots downsampled (if needed) so their count never exceeds +// maxPeriodicRowsPerWindow — see that constant's comment for why. The two +// sets are merged and returned in chronological order. +func (s *SQLiteStore) Recent(ctx context.Context, since time.Time) ([]domain.Snapshot, error) { + periodic, err := s.recentPeriodic(ctx, since) + if err != nil { + return nil, err + } + + incidents, err := s.recentIncidents(ctx, since) + if err != nil { + return nil, err + } + + merged := mergeSnapshotsByTime(periodic, incidents) + return merged, nil +} + +func (s *SQLiteStore) recentPeriodic(ctx context.Context, since time.Time) ([]domain.Snapshot, error) { + var count int + if err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM snapshots WHERE trigger = ? AND captured_at >= ?`, + string(domain.SnapshotTriggerPeriodic), since, + ).Scan(&count); err != nil { + return nil, fmt.Errorf("count periodic snapshots in window: %w", err) + } + + if count == 0 { + return []domain.Snapshot{}, nil + } + + step := 1 + if count > maxPeriodicRowsPerWindow { + step = (count + maxPeriodicRowsPerWindow - 1) / maxPeriodicRowsPerWindow + } + + rows, err := s.db.QueryContext(ctx, ` + SELECT payload FROM ( + SELECT payload, ROW_NUMBER() OVER (ORDER BY captured_at) AS rn + FROM snapshots + WHERE trigger = ? AND captured_at >= ? + ) + WHERE (rn - 1) % ? = 0 + ORDER BY rn + `, string(domain.SnapshotTriggerPeriodic), since, step) + if err != nil { + return nil, fmt.Errorf("query periodic snapshots in window: %w", err) + } + defer func() { _ = rows.Close() }() + + return scanSnapshots(rows) +} + +func (s *SQLiteStore) recentIncidents(ctx context.Context, since time.Time) ([]domain.Snapshot, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT payload FROM snapshots WHERE trigger = ? AND captured_at >= ? ORDER BY captured_at ASC`, + string(domain.SnapshotTriggerIncident), since, + ) + if err != nil { + return nil, fmt.Errorf("query incident snapshots in window: %w", err) + } + defer func() { _ = rows.Close() }() + + return scanSnapshots(rows) +} + +// mergeSnapshotsByTime merges two already-sorted-by-time slices into one +// chronologically ordered slice (a standard merge step, not a full sort, +// since both inputs are already ordered). +func mergeSnapshotsByTime(a, b []domain.Snapshot) []domain.Snapshot { + merged := make([]domain.Snapshot, 0, len(a)+len(b)) + i, j := 0, 0 + for i < len(a) && j < len(b) { + if a[i].CapturedAt.Before(b[j].CapturedAt) { + merged = append(merged, a[i]) + i++ + } else { + merged = append(merged, b[j]) + j++ + } + } + merged = append(merged, a[i:]...) + merged = append(merged, b[j:]...) + return merged +} + +func (s *SQLiteStore) Incidents(ctx context.Context) ([]domain.Snapshot, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT payload FROM snapshots WHERE trigger = ? ORDER BY captured_at ASC`, + string(domain.SnapshotTriggerIncident), + ) + if err != nil { + return nil, fmt.Errorf("query incident snapshots: %w", err) + } + defer func() { _ = rows.Close() }() + + return scanSnapshots(rows) +} + +func scanSnapshots(rows *sql.Rows) ([]domain.Snapshot, error) { + snapshots := make([]domain.Snapshot, 0) + for rows.Next() { + var payload string + if err := rows.Scan(&payload); err != nil { + return nil, fmt.Errorf("scan snapshot row: %w", err) + } + + var snapshot domain.Snapshot + if err := json.Unmarshal([]byte(payload), &snapshot); err != nil { + return nil, fmt.Errorf("unmarshal snapshot payload: %w", err) + } + snapshots = append(snapshots, snapshot) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate snapshot rows: %w", err) + } + return snapshots, nil +} diff --git a/go/internal/infrastructure/history/sqlite_store_test.go b/go/internal/infrastructure/history/sqlite_store_test.go new file mode 100644 index 0000000..4bd38eb --- /dev/null +++ b/go/internal/infrastructure/history/sqlite_store_test.go @@ -0,0 +1,497 @@ +package history + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/fayupable/pgscope/internal/domain" +) + +func newTestStore(t *testing.T) *SQLiteStore { + t.Helper() + path := filepath.Join(t.TempDir(), "pgscope_test.db") + store, err := NewSQLiteStore(path) + if err != nil { + t.Fatalf("NewSQLiteStore() error = %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + return store +} + +func TestSQLiteStore_AppendAndRecent(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + first := domain.Snapshot{ + Sessions: []domain.Session{{ID: "1", User: "app", Query: "SELECT 1", Duration: 2 * time.Second}}, + CapturedAt: time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC), + Trigger: domain.SnapshotTriggerPeriodic, + } + second := domain.Snapshot{ + Sessions: []domain.Session{{ID: "2", User: "app", Query: "SELECT 2", Duration: 3 * time.Second}}, + CapturedAt: time.Date(2026, 1, 1, 10, 0, 30, 0, time.UTC), + Trigger: domain.SnapshotTriggerPeriodic, + } + + if err := store.Append(ctx, first); err != nil { + t.Fatalf("Append(first) error = %v", err) + } + if err := store.Append(ctx, second); err != nil { + t.Fatalf("Append(second) error = %v", err) + } + + got, err := store.Recent(ctx, time.Time{}) + if err != nil { + t.Fatalf("Recent() error = %v", err) + } + if len(got) != 2 { + t.Fatalf("Recent() returned %d snapshots, want 2", len(got)) + } + + if !got[0].CapturedAt.Equal(first.CapturedAt) { + t.Errorf("Recent()[0].CapturedAt = %v, want %v (chronological order)", got[0].CapturedAt, first.CapturedAt) + } + if !got[1].CapturedAt.Equal(second.CapturedAt) { + t.Errorf("Recent()[1].CapturedAt = %v, want %v (chronological order)", got[1].CapturedAt, second.CapturedAt) + } +} + +func TestSQLiteStore_DurationRoundTrips(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + snapshot := domain.Snapshot{ + Sessions: []domain.Session{ + {ID: "1", Duration: 2500 * time.Millisecond}, + }, + CapturedAt: time.Now().UTC(), + Trigger: domain.SnapshotTriggerPeriodic, + } + + if err := store.Append(ctx, snapshot); err != nil { + t.Fatalf("Append() error = %v", err) + } + + got, err := store.Recent(ctx, time.Time{}) + if err != nil { + t.Fatalf("Recent() error = %v", err) + } + if len(got) != 1 || len(got[0].Sessions) != 1 { + t.Fatalf("Recent() = %+v, want exactly 1 snapshot with 1 session", got) + } + + gotDuration := got[0].Sessions[0].Duration + if gotDuration != 2500*time.Millisecond { + t.Errorf("Duration round-trip = %v, want %v", gotDuration, 2500*time.Millisecond) + } +} + +func TestSQLiteStore_RedactsQueryBeforePersisting(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + snapshot := domain.Snapshot{ + Sessions: []domain.Session{ + {ID: "1", Query: "SELECT * FROM users WHERE email = 'ali@example.com'"}, + }, + CapturedAt: time.Now().UTC(), + Trigger: domain.SnapshotTriggerPeriodic, + } + + if err := store.Append(ctx, snapshot); err != nil { + t.Fatalf("Append() error = %v", err) + } + + got, err := store.Recent(ctx, time.Time{}) + if err != nil { + t.Fatalf("Recent() error = %v", err) + } + if len(got) != 1 || len(got[0].Sessions) != 1 { + t.Fatalf("Recent() = %+v, want exactly 1 snapshot with 1 session", got) + } + + gotQuery := got[0].Sessions[0].Query + if strings.Contains(gotQuery, "ali@example.com") { + t.Errorf("Query = %q, want the literal email redacted before persisting", gotQuery) + } + if !strings.Contains(gotQuery, "***") { + t.Errorf("Query = %q, want it to contain the *** redaction marker", gotQuery) + } +} + +func TestSQLiteStore_Incidents(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + periodic := domain.Snapshot{ + Sessions: []domain.Session{{ID: "1"}}, + CapturedAt: time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC), + Trigger: domain.SnapshotTriggerPeriodic, + } + incident := domain.Snapshot{ + Sessions: []domain.Session{{ID: "2"}}, + CapturedAt: time.Date(2026, 1, 1, 10, 5, 0, 0, time.UTC), + Trigger: domain.SnapshotTriggerIncident, + } + + if err := store.Append(ctx, periodic); err != nil { + t.Fatalf("Append(periodic) error = %v", err) + } + if err := store.Append(ctx, incident); err != nil { + t.Fatalf("Append(incident) error = %v", err) + } + + got, err := store.Incidents(ctx) + if err != nil { + t.Fatalf("Incidents() error = %v", err) + } + if len(got) != 1 { + t.Fatalf("Incidents() returned %d snapshots, want 1", len(got)) + } + if got[0].Trigger != domain.SnapshotTriggerIncident { + t.Errorf("Incidents()[0].Trigger = %v, want %v", got[0].Trigger, domain.SnapshotTriggerIncident) + } + if len(got[0].Sessions) != 1 || got[0].Sessions[0].ID != "2" { + t.Errorf("Incidents()[0] = %+v, want the incident snapshot, not the periodic one", got[0]) + } +} + +func TestSQLiteStore_PruneOlderThan(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + old := domain.Snapshot{ + Sessions: []domain.Session{{ID: "old"}}, + CapturedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + Trigger: domain.SnapshotTriggerPeriodic, + } + recent := domain.Snapshot{ + Sessions: []domain.Session{{ID: "recent"}}, + CapturedAt: time.Now().UTC(), + Trigger: domain.SnapshotTriggerPeriodic, + } + oldIncident := domain.Snapshot{ + Sessions: []domain.Session{{ID: "old_incident"}}, + CapturedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + Trigger: domain.SnapshotTriggerIncident, + } + + for _, s := range []domain.Snapshot{old, recent, oldIncident} { + if err := store.Append(ctx, s); err != nil { + t.Fatalf("Append() error = %v", err) + } + } + + cutoff := time.Now().Add(-24 * time.Hour) + if err := store.PruneOlderThan(ctx, cutoff); err != nil { + t.Fatalf("PruneOlderThan() error = %v", err) + } + + // Recent() returns every snapshot regardless of trigger (mirroring + // RingBufferStore's semantics), so the surviving incident snapshot is + // still expected here — only the pruned periodic snapshot should be gone. + remaining, err := store.Recent(ctx, time.Time{}) + if err != nil { + t.Fatalf("Recent() error = %v", err) + } + if len(remaining) != 2 { + t.Fatalf("Recent() after prune returned %d snapshots, want 2 (recent periodic + old incident)", len(remaining)) + } + remainingIDs := map[string]bool{} + for _, snap := range remaining { + remainingIDs[snap.Sessions[0].ID] = true + } + if !remainingIDs["recent"] || !remainingIDs["old_incident"] { + t.Fatalf("Recent() after prune = %+v, want the recent periodic and old_incident snapshots, not the pruned old one", remaining) + } + if remainingIDs["old"] { + t.Fatalf("Recent() after prune still contains the old periodic snapshot, want it pruned") + } + + incidents, err := store.Incidents(ctx) + if err != nil { + t.Fatalf("Incidents() error = %v", err) + } + if len(incidents) != 1 { + t.Fatalf("Incidents() after prune returned %d, want 1 (incidents are never pruned by age)", len(incidents)) + } +} + +func TestSQLiteStore_PruneExcessIncidents(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for i := 0; i < 5; i++ { + incident := domain.Snapshot{ + Sessions: []domain.Session{{ID: string(rune('a' + i))}}, + CapturedAt: base.Add(time.Duration(i) * time.Minute), + Trigger: domain.SnapshotTriggerIncident, + } + if err := store.Append(ctx, incident); err != nil { + t.Fatalf("Append() error = %v", err) + } + } + + if err := store.PruneExcessIncidents(ctx, 3); err != nil { + t.Fatalf("PruneExcessIncidents() error = %v", err) + } + + got, err := store.Incidents(ctx) + if err != nil { + t.Fatalf("Incidents() error = %v", err) + } + if len(got) != 3 { + t.Fatalf("Incidents() after PruneExcessIncidents(3) returned %d, want 3", len(got)) + } + + // The 3 most recent (i=2,3,4) should survive, oldest two (i=0,1) pruned. + cutoff := base.Add(2 * time.Minute) + for _, snap := range got { + if snap.CapturedAt.Before(cutoff) { + t.Errorf("survived incident has CapturedAt = %v, want it at or after %v (the 3 most recent)", snap.CapturedAt, cutoff) + } + } +} + +func TestSQLiteStore_PruneExcessIncidents_NoopWhenUnderLimit(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + incident := domain.Snapshot{ + Sessions: []domain.Session{{ID: "1"}}, + CapturedAt: time.Now().UTC(), + Trigger: domain.SnapshotTriggerIncident, + } + if err := store.Append(ctx, incident); err != nil { + t.Fatalf("Append() error = %v", err) + } + + if err := store.PruneExcessIncidents(ctx, 100); err != nil { + t.Fatalf("PruneExcessIncidents() error = %v", err) + } + + got, err := store.Incidents(ctx) + if err != nil { + t.Fatalf("Incidents() error = %v", err) + } + if len(got) != 1 { + t.Fatalf("Incidents() after no-op prune returned %d, want 1", len(got)) + } +} + +func TestSQLiteStore_Recent_FiltersBySince(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + tooOld := domain.Snapshot{ + Sessions: []domain.Session{{ID: "too_old"}}, + CapturedAt: base, + Trigger: domain.SnapshotTriggerPeriodic, + } + inWindow := domain.Snapshot{ + Sessions: []domain.Session{{ID: "in_window"}}, + CapturedAt: base.Add(2 * time.Hour), + Trigger: domain.SnapshotTriggerPeriodic, + } + + if err := store.Append(ctx, tooOld); err != nil { + t.Fatalf("Append(tooOld) error = %v", err) + } + if err := store.Append(ctx, inWindow); err != nil { + t.Fatalf("Append(inWindow) error = %v", err) + } + + since := base.Add(time.Hour) + got, err := store.Recent(ctx, since) + if err != nil { + t.Fatalf("Recent() error = %v", err) + } + if len(got) != 1 { + t.Fatalf("Recent(since=%v) returned %d snapshots, want 1", since, len(got)) + } + if got[0].Sessions[0].ID != "in_window" { + t.Errorf("Recent(since=%v)[0] = %+v, want the in_window snapshot", since, got[0]) + } +} + +func TestSQLiteStore_Recent_IncludesIncidentsRegardlessOfDownsampling(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for i := 0; i < 5; i++ { + snap := domain.Snapshot{ + Sessions: []domain.Session{{ID: "periodic"}}, + CapturedAt: base.Add(time.Duration(i) * time.Second), + Trigger: domain.SnapshotTriggerPeriodic, + } + if err := store.Append(ctx, snap); err != nil { + t.Fatalf("Append(periodic) error = %v", err) + } + } + incident := domain.Snapshot{ + Sessions: []domain.Session{{ID: "incident"}}, + CapturedAt: base.Add(10 * time.Second), + Trigger: domain.SnapshotTriggerIncident, + } + if err := store.Append(ctx, incident); err != nil { + t.Fatalf("Append(incident) error = %v", err) + } + + got, err := store.Recent(ctx, base) + if err != nil { + t.Fatalf("Recent() error = %v", err) + } + + found := false + for _, snap := range got { + if snap.Trigger == domain.SnapshotTriggerIncident { + found = true + } + } + if !found { + t.Errorf("Recent() = %+v, want the incident snapshot present regardless of periodic volume", got) + } +} + +func TestSQLiteStore_Recent_DownsamplesLargePeriodicWindow(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + total := maxPeriodicRowsPerWindow * 3 + for i := 0; i < total; i++ { + snap := domain.Snapshot{ + Sessions: []domain.Session{{ID: "s"}}, + CapturedAt: base.Add(time.Duration(i) * time.Second), + Trigger: domain.SnapshotTriggerPeriodic, + } + if err := store.Append(ctx, snap); err != nil { + t.Fatalf("Append() error = %v", err) + } + } + + got, err := store.Recent(ctx, base) + if err != nil { + t.Fatalf("Recent() error = %v", err) + } + + if len(got) > maxPeriodicRowsPerWindow { + t.Errorf("Recent() returned %d snapshots, want at most %d (downsampled)", len(got), maxPeriodicRowsPerWindow) + } + if len(got) == 0 { + t.Fatal("Recent() returned 0 snapshots, want a downsampled but non-empty result") + } + + first := got[0].CapturedAt + last := got[len(got)-1].CapturedAt + spanCoveredFraction := last.Sub(first).Seconds() / float64(total) + if spanCoveredFraction < 0.9 { + t.Errorf("downsampled result only spans %.0f%% of the original window, want it spread across nearly the whole thing", spanCoveredFraction*100) + } +} + +func appendPaddedHistory(t *testing.T, ctx context.Context, store *SQLiteStore, base time.Time, periodicCount int) { + t.Helper() + for i := 0; i < periodicCount; i++ { + snap := domain.Snapshot{ + Sessions: []domain.Session{ + {ID: "s", Query: strings.Repeat("x", 500)}, // pad each row so the file grows measurably + }, + CapturedAt: base.Add(time.Duration(i) * time.Second), + Trigger: domain.SnapshotTriggerPeriodic, + } + if err := store.Append(ctx, snap); err != nil { + t.Fatalf("Append() error = %v", err) + } + } + incident := domain.Snapshot{ + Sessions: []domain.Session{{ID: "incident", Query: strings.Repeat("y", 500)}}, + CapturedAt: base.Add(time.Duration(periodicCount+1) * time.Second), + Trigger: domain.SnapshotTriggerIncident, + } + if err := store.Append(ctx, incident); err != nil { + t.Fatalf("Append(incident) error = %v", err) + } +} + +func countByTrigger(snapshots []domain.Snapshot) (periodic, incident int) { + for _, snap := range snapshots { + if snap.Trigger == domain.SnapshotTriggerIncident { + incident++ + } else { + periodic++ + } + } + return periodic, incident +} + +func TestSQLiteStore_EnforceMaxSize_PrunesPeriodicFirstAndLeavesIncidentsAlone(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + // pruneBatchSize is 500 — use more than that so a single batch can't + // possibly delete every periodic row at once, letting a "moderate cap" + // scenario (satisfied by only partial pruning) actually be exercised. + appendPaddedHistory(t, ctx, store, base, 1200) + + sizeBefore, err := store.fileSize() + if err != nil { + t.Fatalf("fileSize() error = %v", err) + } + + // A moderate cap: enough pruning is needed to trigger enforcement, but + // deleting only part of the periodic set should already bring the file + // back under it — incidents should never be touched in this case. + moderateCap := sizeBefore * 3 / 4 + if err := store.EnforceMaxSize(ctx, moderateCap); err != nil { + t.Fatalf("EnforceMaxSize() error = %v", err) + } + + remaining, err := store.Recent(ctx, base) + if err != nil { + t.Fatalf("Recent() error = %v", err) + } + periodicRemaining, incidentRemaining := countByTrigger(remaining) + + if periodicRemaining >= 1200 { + t.Errorf("periodic snapshots remaining = %d, want fewer than the original 1200 (enforcement should have pruned some)", periodicRemaining) + } + if incidentRemaining != 1 { + t.Errorf("incident snapshots remaining = %d, want 1 (a moderate cap should never need to touch incidents)", incidentRemaining) + } +} + +func TestSQLiteStore_EnforceMaxSize_FallsBackToIncidentsWhenPeriodicAloneIsNotEnough(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + appendPaddedHistory(t, ctx, store, base, 20) + + // An impossibly small cap: even deleting every periodic snapshot can't + // satisfy it, so EnforceMaxSize must fall back to pruning incidents too. + const impossiblyTinyCap = 1 + if err := store.EnforceMaxSize(ctx, impossiblyTinyCap); err != nil { + t.Fatalf("EnforceMaxSize() error = %v", err) + } + + remaining, err := store.Recent(ctx, base) + if err != nil { + t.Fatalf("Recent() error = %v", err) + } + periodicRemaining, incidentRemaining := countByTrigger(remaining) + + if periodicRemaining != 0 { + t.Errorf("periodic snapshots remaining = %d, want 0 (an impossibly tiny cap should exhaust periodic first)", periodicRemaining) + } + if incidentRemaining != 0 { + t.Errorf("incident snapshots remaining = %d, want 0 (once periodic is exhausted, an impossibly tiny cap must fall back to pruning incidents)", incidentRemaining) + } +} diff --git a/go/internal/infrastructure/postgres/database_stats_collector.go b/go/internal/infrastructure/postgres/database_stats_collector.go index 4360bdb..1fbd1a2 100644 --- a/go/internal/infrastructure/postgres/database_stats_collector.go +++ b/go/internal/infrastructure/postgres/database_stats_collector.go @@ -15,19 +15,20 @@ SELECT COALESCE(SUM(xact_commit), 0), COALESCE(SUM(xact_rollback), 0), COALESCE(SUM(blks_hit), 0), - COALESCE(SUM(blks_read), 0) + COALESCE(SUM(blks_read), 0), + COALESCE(SUM(temp_bytes), 0) FROM pg_stat_database WHERE datname = current_database() ` // DatabaseStatsCollector implements output.IDatabaseStatsCollectorPort -// against Postgres's pg_stat_database view. xact_commit/xact_rollback are -// cumulative counters since the database was created, so this adapter keeps -// the previous measurement in memory and reports the rate of change between -// calls rather than the raw counters, which are not meaningful to display -// live on their own. blks_hit/blks_read are reported as a cumulative ratio -// instead — cache hit ratio is conventionally read as a running average, -// not a per-tick rate. +// against Postgres's pg_stat_database view. xact_commit/xact_rollback/ +// temp_bytes are cumulative counters since the database was created, so +// this adapter keeps the previous measurement in memory and reports the +// rate of change between calls rather than the raw counters, which are not +// meaningful to display live on their own. blks_hit/blks_read are reported +// as a cumulative ratio instead — cache hit ratio is conventionally read as +// a running average, not a per-tick rate. type DatabaseStatsCollector struct { pool *pgxpool.Pool @@ -35,6 +36,7 @@ type DatabaseStatsCollector struct { hasBaseline bool lastCommits int64 lastRollback int64 + lastTempByte int64 lastMeasured time.Time } @@ -43,29 +45,29 @@ func NewDatabaseStatsCollector(pool *pgxpool.Pool) *DatabaseStatsCollector { } func (c *DatabaseStatsCollector) FetchDatabaseStats(ctx context.Context) (domain.DatabaseActivityStats, error) { - commits, rollbacks, blksHit, blksRead, err := c.queryCounters(ctx) + commits, rollbacks, blksHit, blksRead, tempBytes, err := c.queryCounters(ctx) if err != nil { return domain.DatabaseActivityStats{}, err } - stats := c.computeRate(commits, rollbacks) + stats := c.computeRate(commits, rollbacks, tempBytes) stats.CacheHitRatio = cacheHitRatio(blksHit, blksRead) return stats, nil } -func (c *DatabaseStatsCollector) queryCounters(ctx context.Context) (commits, rollbacks, blksHit, blksRead int64, err error) { - err = c.pool.QueryRow(ctx, databaseStatsQuery).Scan(&commits, &rollbacks, &blksHit, &blksRead) - return commits, rollbacks, blksHit, blksRead, err +func (c *DatabaseStatsCollector) queryCounters(ctx context.Context) (commits, rollbacks, blksHit, blksRead, tempBytes int64, err error) { + err = c.pool.QueryRow(ctx, databaseStatsQuery).Scan(&commits, &rollbacks, &blksHit, &blksRead, &tempBytes) + return commits, rollbacks, blksHit, blksRead, tempBytes, err } -func (c *DatabaseStatsCollector) computeRate(commits, rollbacks int64) domain.DatabaseActivityStats { +func (c *DatabaseStatsCollector) computeRate(commits, rollbacks, tempBytes int64) domain.DatabaseActivityStats { c.mu.Lock() defer c.mu.Unlock() now := time.Now() if !c.hasBaseline { - c.setBaseline(commits, rollbacks, now) + c.setBaseline(commits, rollbacks, tempBytes, now) return domain.DatabaseActivityStats{MeasuredAt: now} } @@ -73,17 +75,19 @@ func (c *DatabaseStatsCollector) computeRate(commits, rollbacks int64) domain.Da stats := domain.DatabaseActivityStats{ CommitsPerSecond: rate(commits-c.lastCommits, elapsed), RollbacksPerSecond: rate(rollbacks-c.lastRollback, elapsed), + TempBytesPerSecond: rate(tempBytes-c.lastTempByte, elapsed), MeasuredAt: now, } - c.setBaseline(commits, rollbacks, now) + c.setBaseline(commits, rollbacks, tempBytes, now) return stats } -func (c *DatabaseStatsCollector) setBaseline(commits, rollbacks int64, at time.Time) { +func (c *DatabaseStatsCollector) setBaseline(commits, rollbacks, tempBytes int64, at time.Time) { c.hasBaseline = true c.lastCommits = commits c.lastRollback = rollbacks + c.lastTempByte = tempBytes c.lastMeasured = at } diff --git a/go/internal/infrastructure/postgres/database_stats_collector_test.go b/go/internal/infrastructure/postgres/database_stats_collector_test.go new file mode 100644 index 0000000..fdefbdb --- /dev/null +++ b/go/internal/infrastructure/postgres/database_stats_collector_test.go @@ -0,0 +1,89 @@ +package postgres + +import ( + "testing" + "time" +) + +func TestDatabaseStatsCollector_ComputeRate(t *testing.T) { + c := &DatabaseStatsCollector{} + + t.Run("first call establishes a baseline and reports zero rates", func(t *testing.T) { + stats := c.computeRate(100, 10, 5000) + if stats.CommitsPerSecond != 0 || stats.RollbacksPerSecond != 0 || stats.TempBytesPerSecond != 0 { + t.Errorf("first call = %+v, want all rates zero (no prior baseline to compare against)", stats) + } + }) + + t.Run("second call computes a rate from the delta since the baseline", func(t *testing.T) { + c.lastMeasured = time.Now().Add(-2 * time.Second) // force a known elapsed time + + stats := c.computeRate(120, 12, 6000) // +20 commits, +2 rollbacks, +1000 temp bytes over ~2s + + if stats.CommitsPerSecond < 9 || stats.CommitsPerSecond > 11 { + t.Errorf("CommitsPerSecond = %v, want approx 10", stats.CommitsPerSecond) + } + if stats.RollbacksPerSecond < 0.9 || stats.RollbacksPerSecond > 1.1 { + t.Errorf("RollbacksPerSecond = %v, want approx 1", stats.RollbacksPerSecond) + } + if stats.TempBytesPerSecond < 450 || stats.TempBytesPerSecond > 550 { + t.Errorf("TempBytesPerSecond = %v, want approx 500", stats.TempBytesPerSecond) + } + }) + + t.Run("counters that haven't moved report a zero rate, not negative or NaN", func(t *testing.T) { + c.lastMeasured = time.Now().Add(-1 * time.Second) + stats := c.computeRate(120, 12, 6000) // identical to the previous call's counters + + if stats.CommitsPerSecond != 0 || stats.RollbacksPerSecond != 0 || stats.TempBytesPerSecond != 0 { + t.Errorf("unchanged counters = %+v, want all rates zero", stats) + } + }) +} + +func TestRate(t *testing.T) { + tests := []struct { + name string + delta int64 + elapsedSeconds float64 + want float64 + }{ + {name: "positive delta over one second", delta: 100, elapsedSeconds: 1, want: 100}, + {name: "positive delta over two seconds", delta: 100, elapsedSeconds: 2, want: 50}, + {name: "zero delta", delta: 0, elapsedSeconds: 5, want: 0}, + {name: "zero elapsed time avoids division by zero", delta: 100, elapsedSeconds: 0, want: 0}, + {name: "negative elapsed time (clock skew) avoids a nonsensical negative rate", delta: 100, elapsedSeconds: -1, want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := rate(tt.delta, tt.elapsedSeconds); got != tt.want { + t.Errorf("rate(%d, %v) = %v, want %v", tt.delta, tt.elapsedSeconds, got, tt.want) + } + }) + } +} + +func TestCacheHitRatio(t *testing.T) { + tests := []struct { + name string + blksHit int64 + blksRead int64 + want float64 + }{ + {name: "no reads at all is treated as a perfect ratio", blksHit: 0, blksRead: 0, want: 100}, + {name: "all hits, no misses", blksHit: 1000, blksRead: 0, want: 100}, + {name: "all misses, no hits", blksHit: 0, blksRead: 1000, want: 0}, + {name: "even split", blksHit: 500, blksRead: 500, want: 50}, + {name: "mostly hits", blksHit: 998, blksRead: 2, want: 99.8}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cacheHitRatio(tt.blksHit, tt.blksRead) + if got < tt.want-0.01 || got > tt.want+0.01 { + t.Errorf("cacheHitRatio(%d, %d) = %v, want %v", tt.blksHit, tt.blksRead, got, tt.want) + } + }) + } +} diff --git a/go/internal/infrastructure/postgres/insights_collector.go b/go/internal/infrastructure/postgres/insights_collector.go index 3399d7e..3615f37 100644 --- a/go/internal/infrastructure/postgres/insights_collector.go +++ b/go/internal/infrastructure/postgres/insights_collector.go @@ -33,6 +33,10 @@ type InsightsCollector struct { checkpointHealth *CheckpointHealthCollector replicationLag *ReplicationLagCollector kcache *KcacheCollector + preparedTransactions *PreparedTransactionCollector + replicationSlots *ReplicationSlotCollector + longRunningQueries *LongRunningQueryCollector + unloggedTables *UnloggedTableCollector } func NewInsightsCollector(pool *pgxpool.Pool) *InsightsCollector { @@ -55,6 +59,10 @@ func NewInsightsCollector(pool *pgxpool.Pool) *InsightsCollector { checkpointHealth: NewCheckpointHealthCollector(pool), replicationLag: NewReplicationLagCollector(pool), kcache: NewKcacheCollector(pool), + preparedTransactions: NewPreparedTransactionCollector(pool), + replicationSlots: NewReplicationSlotCollector(pool), + longRunningQueries: NewLongRunningQueryCollector(pool), + unloggedTables: NewUnloggedTableCollector(pool), } } @@ -150,28 +158,53 @@ func (c *InsightsCollector) FetchInsights(ctx context.Context) (domain.Insights, return domain.Insights{}, err } } + + preparedTransactions, err := c.preparedTransactions.Fetch(ctx) + if err != nil { + return domain.Insights{}, err + } + + replicationSlots, err := c.replicationSlots.Fetch(ctx) + if err != nil { + return domain.Insights{}, err + } + + longRunningQueries, err := c.longRunningQueries.Fetch(ctx) + if err != nil { + return domain.Insights{}, err + } + + unloggedTables, err := c.unloggedTables.Fetch(ctx) + if err != nil { + return domain.Insights{}, err + } + return domain.Insights{ - TopQueries: topQueries, - IndexCandidates: indexCandidates, - DuplicateIndexes: duplicateIndexes, - UnusedIndexes: unusedIndexes, - FunctionCosts: functionCosts, - TrackFunctionsEnabled: trackFunctionsSetting != "none", - TrackFunctionsSetting: trackFunctionsSetting, - PaginationWarnings: paginationWarnings, - NestedStatementsTracked: statementsTrackSetting == "all", - StatementsTrackSetting: statementsTrackSetting, - DatabaseSize: databaseSize, - ConnectionSaturation: connectionSaturation, - SequenceOverflowRisks: domain.DetectSequenceOverflowRisks(sequenceUsages), - InvalidIndexes: invalidIndexes, - UnvalidatedConstraints: unvalidatedConstraints, - VacuumHealthWarnings: domain.DetectVacuumHealthWarnings(vacuumStats), - IdleInTransactionWarnings: domain.DetectIdleInTransactionWarnings(idleSessions), - CheckpointHealth: domain.NewCheckpointHealth(checkpointStats), - ReplicationLagWarnings: domain.DetectReplicationLagWarnings(replicaLags), - PhysicalIOEnabled: physicalIOEnabled, - PhysicalIOHotspots: domain.DetectPhysicalIOHotspots(physicalIOStats), + TopQueries: topQueries, + IndexCandidates: indexCandidates, + DuplicateIndexes: duplicateIndexes, + UnusedIndexes: unusedIndexes, + FunctionCosts: functionCosts, + TrackFunctionsEnabled: trackFunctionsSetting != "none", + TrackFunctionsSetting: trackFunctionsSetting, + PaginationWarnings: paginationWarnings, + NestedStatementsTracked: statementsTrackSetting == "all", + StatementsTrackSetting: statementsTrackSetting, + DatabaseSize: databaseSize, + ConnectionSaturation: connectionSaturation, + SequenceOverflowRisks: domain.DetectSequenceOverflowRisks(sequenceUsages), + InvalidIndexes: invalidIndexes, + UnvalidatedConstraints: unvalidatedConstraints, + VacuumHealthWarnings: domain.DetectVacuumHealthWarnings(vacuumStats), + IdleInTransactionWarnings: domain.DetectIdleInTransactionWarnings(idleSessions), + CheckpointHealth: domain.NewCheckpointHealth(checkpointStats), + ReplicationLagWarnings: domain.DetectReplicationLagWarnings(replicaLags), + PhysicalIOEnabled: physicalIOEnabled, + PhysicalIOHotspots: domain.DetectPhysicalIOHotspots(physicalIOStats), + PreparedTransactionWarnings: domain.DetectPreparedTransactionWarnings(preparedTransactions), + ReplicationSlotWarnings: domain.DetectReplicationSlotWarnings(replicationSlots), + LongRunningQueryWarnings: domain.DetectLongRunningQueryWarnings(longRunningQueries), + UnloggedTables: unloggedTables, }, nil } diff --git a/go/internal/infrastructure/postgres/long_running_query_collector.go b/go/internal/infrastructure/postgres/long_running_query_collector.go new file mode 100644 index 0000000..5f4fd9f --- /dev/null +++ b/go/internal/infrastructure/postgres/long_running_query_collector.go @@ -0,0 +1,58 @@ +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/fayupable/pgscope/internal/domain" +) + +const longRunningQueryQuery = ` +SELECT + a.pid, + COALESCE(a.usename, ''), + COALESCE(a.application_name, ''), + COALESCE(s.query, '[query not tracked by pg_stat_statements]'), + EXTRACT(EPOCH FROM now() - a.query_start) +FROM pg_stat_activity a +LEFT JOIN pg_stat_statements s ON s.queryid = a.query_id +WHERE a.state = 'active' + AND a.datname = current_database() + AND a.pid != pg_backend_pid() +` + +// LongRunningQueryCollector reads sessions currently executing a query. +// Query text always comes from pg_stat_statements (parameter values +// replaced with $1, $2, ...), never from pg_stat_activity's raw query +// column — same reasoning as Collector in collector.go: this prevents +// literal values (passwords, PII, tokens) that appear in a running +// statement from ever being exposed through the tool. It has no opinion on +// how long is "too long" — that judgment belongs to +// domain.DetectLongRunningQueryWarnings. +type LongRunningQueryCollector struct { + pool *pgxpool.Pool +} + +func NewLongRunningQueryCollector(pool *pgxpool.Pool) *LongRunningQueryCollector { + return &LongRunningQueryCollector{pool: pool} +} + +func (c *LongRunningQueryCollector) Fetch(ctx context.Context) ([]domain.LongRunningQuerySession, error) { + rows, err := c.pool.Query(ctx, longRunningQueryQuery) + if err != nil { + return nil, err + } + defer rows.Close() + + sessions := make([]domain.LongRunningQuerySession, 0) + for rows.Next() { + var s domain.LongRunningQuerySession + if err := rows.Scan(&s.PID, &s.User, &s.ApplicationName, &s.Query, &s.RunningSeconds); err != nil { + return nil, err + } + sessions = append(sessions, s) + } + + return sessions, rows.Err() +} diff --git a/go/internal/infrastructure/postgres/prepared_transaction_collector.go b/go/internal/infrastructure/postgres/prepared_transaction_collector.go new file mode 100644 index 0000000..0c43829 --- /dev/null +++ b/go/internal/infrastructure/postgres/prepared_transaction_collector.go @@ -0,0 +1,52 @@ +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/fayupable/pgscope/internal/domain" +) + +const preparedTransactionQuery = ` +SELECT + gid, + database, + owner, + EXTRACT(EPOCH FROM now() - prepared) +FROM pg_prepared_xacts +` + +// PreparedTransactionCollector reads two-phase-commit transactions still +// awaiting COMMIT PREPARED or ROLLBACK PREPARED, straight from +// pg_prepared_xacts — this view is not filtered by database, since a +// forgotten prepared transaction on any database in the cluster still +// holds locks and blocks vacuum cluster-wide. It has no opinion on how +// long is "too long" — that judgment belongs to +// domain.DetectPreparedTransactionWarnings. +type PreparedTransactionCollector struct { + pool *pgxpool.Pool +} + +func NewPreparedTransactionCollector(pool *pgxpool.Pool) *PreparedTransactionCollector { + return &PreparedTransactionCollector{pool: pool} +} + +func (c *PreparedTransactionCollector) Fetch(ctx context.Context) ([]domain.PreparedTransactionInfo, error) { + rows, err := c.pool.Query(ctx, preparedTransactionQuery) + if err != nil { + return nil, err + } + defer rows.Close() + + transactions := make([]domain.PreparedTransactionInfo, 0) + for rows.Next() { + var tx domain.PreparedTransactionInfo + if err := rows.Scan(&tx.GID, &tx.Database, &tx.Owner, &tx.AgeSeconds); err != nil { + return nil, err + } + transactions = append(transactions, tx) + } + + return transactions, rows.Err() +} diff --git a/go/internal/infrastructure/postgres/replication_slot_collector.go b/go/internal/infrastructure/postgres/replication_slot_collector.go new file mode 100644 index 0000000..a73c7f5 --- /dev/null +++ b/go/internal/infrastructure/postgres/replication_slot_collector.go @@ -0,0 +1,54 @@ +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/fayupable/pgscope/internal/domain" +) + +// replicationSlotQuery reads every replication slot from +// pg_replication_slots, regardless of whether a replica is currently +// connected to consume it — an inactive slot still retains WAL +// indefinitely, which is exactly the failure mode this check exists to +// catch. restart_lsn is NULL for a slot that's never been used yet, so +// COALESCE it against the current WAL position (zero retained bytes, +// nothing to warn about). +const replicationSlotQuery = ` +SELECT + slot_name, + active, + pg_wal_lsn_diff(pg_current_wal_lsn(), COALESCE(restart_lsn, pg_current_wal_lsn())) +FROM pg_replication_slots +` + +// ReplicationSlotCollector reads replication slot WAL retention directly +// from pg_replication_slots. It has no opinion on what's "too much" — +// that judgment belongs to domain.DetectReplicationSlotWarnings. +type ReplicationSlotCollector struct { + pool *pgxpool.Pool +} + +func NewReplicationSlotCollector(pool *pgxpool.Pool) *ReplicationSlotCollector { + return &ReplicationSlotCollector{pool: pool} +} + +func (c *ReplicationSlotCollector) Fetch(ctx context.Context) ([]domain.ReplicationSlotInfo, error) { + rows, err := c.pool.Query(ctx, replicationSlotQuery) + if err != nil { + return nil, err + } + defer rows.Close() + + slots := make([]domain.ReplicationSlotInfo, 0) + for rows.Next() { + var s domain.ReplicationSlotInfo + if err := rows.Scan(&s.SlotName, &s.Active, &s.RetainedBytes); err != nil { + return nil, err + } + slots = append(slots, s) + } + + return slots, rows.Err() +} diff --git a/go/internal/infrastructure/postgres/unlogged_table_collector.go b/go/internal/infrastructure/postgres/unlogged_table_collector.go new file mode 100644 index 0000000..453102e --- /dev/null +++ b/go/internal/infrastructure/postgres/unlogged_table_collector.go @@ -0,0 +1,50 @@ +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/fayupable/pgscope/internal/domain" +) + +// unloggedTablesQuery reads ordinary tables (relkind = 'r', so views, +// sequences, etc. are excluded) marked UNLOGGED (relpersistence = 'u') in +// the catalog — no judgment needed here, Postgres itself already recorded +// this at CREATE TABLE time, this just surfaces it. +const unloggedTablesQuery = ` +SELECT t.relname +FROM pg_class t +JOIN pg_namespace n ON n.oid = t.relnamespace +WHERE t.relkind = 'r' + AND t.relpersistence = 'u' + AND n.nspname = 'public' +` + +// UnloggedTableCollector reads tables the catalog marks as UNLOGGED. +type UnloggedTableCollector struct { + pool *pgxpool.Pool +} + +func NewUnloggedTableCollector(pool *pgxpool.Pool) *UnloggedTableCollector { + return &UnloggedTableCollector{pool: pool} +} + +func (c *UnloggedTableCollector) Fetch(ctx context.Context) ([]domain.UnloggedTable, error) { + rows, err := c.pool.Query(ctx, unloggedTablesQuery) + if err != nil { + return nil, err + } + defer 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.NewUnloggedTable(table)) + } + + return result, rows.Err() +} diff --git a/go/internal/presentation/http/handlers.go b/go/internal/presentation/http/handlers.go index 0d4f8b6..6667f2e 100644 --- a/go/internal/presentation/http/handlers.go +++ b/go/internal/presentation/http/handlers.go @@ -6,10 +6,24 @@ import ( "fmt" "net/http" "strconv" + "time" "github.com/fayupable/pgscope/internal/application/service" ) +// allowedHistoryWindows are the only replay windows the history endpoint +// accepts — a fixed set, not an arbitrary duration, so a client can't +// request something unreasonably wide. +var allowedHistoryWindows = map[string]time.Duration{ + "1h": 1 * time.Hour, + "3h": 3 * time.Hour, + "6h": 6 * time.Hour, + "12h": 12 * time.Hour, + "24h": 24 * time.Hour, +} + +const defaultHistoryWindow = "1h" + func handleHealth(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) @@ -37,37 +51,39 @@ func handleMonitorStop(poller *service.Poller) http.HandlerFunc { } } -func handleRecordStart(poller *service.Poller) http.HandlerFunc { +// handleHistory serves the replay window: ?window=1h|3h|6h|12h|24h (defaults +// to 1h). Recording itself isn't a separate action — it happens +// automatically in the background whenever monitoring is active — so this +// endpoint only ever reads what's already been recorded. +func handleHistory(poller *service.Poller) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - minutes, err := parseMinutesParam(r) + window, err := parseWindowParam(r) if err != nil { writeError(w, err, http.StatusBadRequest) return } - if err := poller.StartRecording(minutes); err != nil { - writeError(w, err, http.StatusBadRequest) + + since := time.Now().Add(-window) + snapshots, err := poller.RecentHistory(r.Context(), since) + if err != nil { + writeError(w, err, http.StatusInternalServerError) return } - writeJSON(w, map[string]any{"recording": true, "minutes": minutes}) + writeJSON(w, snapshots) } } -func handleRecordStop(poller *service.Poller) http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { - poller.StopRecording() - writeJSON(w, map[string]any{"recording": false}) +func parseWindowParam(r *http.Request) (time.Duration, error) { + raw := r.URL.Query().Get("window") + if raw == "" { + raw = defaultHistoryWindow } -} -func handleHistory(poller *service.Poller) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - snapshots, err := poller.RecentHistory(r.Context()) - if err != nil { - writeError(w, err, http.StatusInternalServerError) - return - } - writeJSON(w, snapshots) + window, ok := allowedHistoryWindows[raw] + if !ok { + return 0, fmt.Errorf("window must be one of: 1h, 3h, 6h, 12h, 24h, got %q", raw) } + return window, nil } func parseMinutesParam(r *http.Request) (int, error) { diff --git a/go/internal/presentation/http/router.go b/go/internal/presentation/http/router.go index 1dbfede..bb5bb3f 100644 --- a/go/internal/presentation/http/router.go +++ b/go/internal/presentation/http/router.go @@ -32,8 +32,6 @@ func NewRouter(broadcaster *sse.Broadcaster, poller *service.Poller, insightsSer 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("POST /api/v1/record/start", withAuth(cfg.APIKey, withRateLimit(generalLimiter, handleRecordStart(poller)))) - mux.Handle("POST /api/v1/record/stop", withAuth(cfg.APIKey, withRateLimit(generalLimiter, handleRecordStop(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))))) diff --git a/web/src/features/dashboard/components/MonitoringControls.css b/web/src/features/dashboard/components/MonitoringControls.css index f4b9a5d..4f1220f 100644 --- a/web/src/features/dashboard/components/MonitoringControls.css +++ b/web/src/features/dashboard/components/MonitoringControls.css @@ -61,17 +61,6 @@ border-color: var(--status-success); } -.monitoring-controls__stop--recording { - border-color: var(--status-danger); - color: var(--status-danger); - background: rgba(224, 58, 58, 0.1); - font-weight: 600; -} - -.monitoring-controls__stop--recording:hover { - border-color: var(--status-danger); -} - .monitoring-controls__download { color: var(--text-secondary); } diff --git a/web/src/features/dashboard/components/MonitoringControls.tsx b/web/src/features/dashboard/components/MonitoringControls.tsx index 5ab453f..8d0e37f 100644 --- a/web/src/features/dashboard/components/MonitoringControls.tsx +++ b/web/src/features/dashboard/components/MonitoringControls.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' -import { startMonitoring, stopMonitoring, startRecording, stopRecording, downloadHistory } from '../../../shared/api/controlClient' +import { startMonitoring, stopMonitoring, downloadHistory } from '../../../shared/api/controlClient' +import type { HistoryWindow } from '../../../shared/api/controlClient' import './MonitoringControls.css' const MONITOR_OPTIONS = [ @@ -9,18 +10,18 @@ const MONITOR_OPTIONS = [ { label: 'Full', value: 0 }, ] -const RECORD_OPTIONS = [ - { label: '5 min', value: 5 }, - { label: '10 min', value: 10 }, - { label: '15 min', value: 15 }, - { label: '30 min', value: 30 }, +const WINDOW_OPTIONS: { label: string; value: HistoryWindow }[] = [ + { label: '1 hour', value: '1h' }, + { label: '3 hours', value: '3h' }, + { label: '6 hours', value: '6h' }, + { label: '12 hours', value: '12h' }, + { label: '24 hours', value: '24h' }, ] export function MonitoringControls() { const [monitorMinutes, setMonitorMinutes] = useState(MONITOR_OPTIONS[0].value) - const [recordMinutes, setRecordMinutes] = useState(RECORD_OPTIONS[0].value) + const [historyWindow, setHistoryWindow] = useState(WINDOW_OPTIONS[0].value) const [isMonitoring, setIsMonitoring] = useState(false) - const [isRecording, setIsRecording] = useState(false) const [error, setError] = useState(null) async function handleAction(action: () => Promise, onSuccess: () => void) { @@ -47,29 +48,20 @@ export function MonitoringControls() { Start ) : ( - )}
- Record - setHistoryWindow(e.target.value as HistoryWindow)}> + {WINDOW_OPTIONS.map((opt) => ( ))} - {!isRecording ? ( - - ) : ( - - )} -
diff --git a/web/src/features/db-stats/components/DbStatsBar.tsx b/web/src/features/db-stats/components/DbStatsBar.tsx index 5f8c197..f728888 100644 --- a/web/src/features/db-stats/components/DbStatsBar.tsx +++ b/web/src/features/db-stats/components/DbStatsBar.tsx @@ -27,6 +27,23 @@ export function DbStatsBar() { cache hit ratio +
+ 1024 * 1024 ? ' db-stats-bar__value--rollback' : '') + } + > + {formatBytesPerSecond(stats.tempBytesPerSecond)} + + temp files/s +
) +} + +function formatBytesPerSecond(bytesPerSecond: number): string { + if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s` + if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s` + return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s` } \ No newline at end of file diff --git a/web/src/features/insights/components/HealthPanel.tsx b/web/src/features/insights/components/HealthPanel.tsx index 54aa1ab..ff69fae 100644 --- a/web/src/features/insights/components/HealthPanel.tsx +++ b/web/src/features/insights/components/HealthPanel.tsx @@ -4,9 +4,13 @@ import type { DatabaseSizeInfo, IdleInTransactionWarning, InvalidIndex, + LongRunningQueryWarning, PhysicalIOHotspot, + PreparedTransactionWarning, ReplicationLagWarning, + ReplicationSlotWarning, SequenceOverflowRisk, + UnloggedTable, UnvalidatedConstraint, VacuumHealthWarning, } from '../../../shared/types/insights' @@ -20,6 +24,10 @@ import { IdleInTransactionTable } from './IdleInTransactionTable' import { CheckpointHealthCard } from './CheckpointHealthCard' import { ReplicationLagTable } from './ReplicationLagTable' import { PhysicalIOHotspotsTable } from './PhysicalIOHotspotsTable' +import { PreparedTransactionsCard } from './PreparedTransactionsCard' +import { ReplicationSlotsCard } from './ReplicationSlotsCard' +import { LongRunningQueriesCard } from './LongRunningQueriesCard' +import { UnloggedTablesCard } from './UnloggedTablesCard' export function HealthPanel({ databaseSize, @@ -33,6 +41,10 @@ export function HealthPanel({ replicationLagWarnings, physicalIOEnabled, physicalIOHotspots, + preparedTransactionWarnings, + replicationSlotWarnings, + longRunningQueryWarnings, + unloggedTables, }: { databaseSize: DatabaseSizeInfo connectionSaturation: ConnectionSaturation @@ -45,6 +57,10 @@ export function HealthPanel({ replicationLagWarnings: ReplicationLagWarning[] physicalIOEnabled: boolean physicalIOHotspots: PhysicalIOHotspot[] + preparedTransactionWarnings: PreparedTransactionWarning[] + replicationSlotWarnings: ReplicationSlotWarning[] + longRunningQueryWarnings: LongRunningQueryWarning[] + unloggedTables: UnloggedTable[] }) { return (
@@ -54,6 +70,10 @@ export function HealthPanel({ + + + +
Vacuum health
@@ -72,4 +92,4 @@ export function HealthPanel({ ) -} \ No newline at end of file +} diff --git a/web/src/features/insights/components/InsightsPanel.tsx b/web/src/features/insights/components/InsightsPanel.tsx index 31bb90e..f85a365 100644 --- a/web/src/features/insights/components/InsightsPanel.tsx +++ b/web/src/features/insights/components/InsightsPanel.tsx @@ -105,6 +105,10 @@ export function InsightsPanel() { replicationLagWarnings={insights.replicationLagWarnings} physicalIOEnabled={insights.physicalIOEnabled} physicalIOHotspots={insights.physicalIOHotspots} + preparedTransactionWarnings={insights.preparedTransactionWarnings} + replicationSlotWarnings={insights.replicationSlotWarnings} + longRunningQueryWarnings={insights.longRunningQueryWarnings} + unloggedTables={insights.unloggedTables} /> )} diff --git a/web/src/features/insights/components/LongRunningQueriesCard.tsx b/web/src/features/insights/components/LongRunningQueriesCard.tsx new file mode 100644 index 0000000..49c94eb --- /dev/null +++ b/web/src/features/insights/components/LongRunningQueriesCard.tsx @@ -0,0 +1,28 @@ +import type { LongRunningQueryWarning } from '../../../shared/types/insights' + +export function LongRunningQueriesCard({ warnings }: { warnings: LongRunningQueryWarning[] }) { + const hasIssues = warnings.length > 0 + + return ( +
+
+ + {hasIssues ? '⚠' : '✓'} + + Long-running queries +
+ + {!hasIssues ? ( +

No long-running active queries.

+ ) : ( +
    + {warnings.map((w) => ( +
  • + PID {w.pid} ({w.runningSeconds.toFixed(0)}s): {w.explanation} +
  • + ))} +
+ )} +
+ ) +} diff --git a/web/src/features/insights/components/PreparedTransactionsCard.tsx b/web/src/features/insights/components/PreparedTransactionsCard.tsx new file mode 100644 index 0000000..e9768ce --- /dev/null +++ b/web/src/features/insights/components/PreparedTransactionsCard.tsx @@ -0,0 +1,28 @@ +import type { PreparedTransactionWarning } from '../../../shared/types/insights' + +export function PreparedTransactionsCard({ warnings }: { warnings: PreparedTransactionWarning[] }) { + const hasIssues = warnings.length > 0 + + return ( +
+
+ + {hasIssues ? '⚠' : '✓'} + + Prepared transactions +
+ + {!hasIssues ? ( +

No orphaned prepared transactions.

+ ) : ( +
    + {warnings.map((w) => ( +
  • + {w.gid}: {w.explanation} +
  • + ))} +
+ )} +
+ ) +} diff --git a/web/src/features/insights/components/ReplicationSlotsCard.tsx b/web/src/features/insights/components/ReplicationSlotsCard.tsx new file mode 100644 index 0000000..d4ad412 --- /dev/null +++ b/web/src/features/insights/components/ReplicationSlotsCard.tsx @@ -0,0 +1,28 @@ +import type { ReplicationSlotWarning } from '../../../shared/types/insights' + +export function ReplicationSlotsCard({ warnings }: { warnings: ReplicationSlotWarning[] }) { + const hasIssues = warnings.length > 0 + + return ( +
+
+ + {hasIssues ? '⚠' : '✓'} + + Replication slots +
+ + {!hasIssues ? ( +

No replication slots retaining excessive WAL.

+ ) : ( +
    + {warnings.map((w) => ( +
  • + {w.slotName}: {w.explanation} +
  • + ))} +
+ )} +
+ ) +} diff --git a/web/src/features/insights/components/UnloggedTablesCard.tsx b/web/src/features/insights/components/UnloggedTablesCard.tsx new file mode 100644 index 0000000..85cb6e2 --- /dev/null +++ b/web/src/features/insights/components/UnloggedTablesCard.tsx @@ -0,0 +1,28 @@ +import type { UnloggedTable } from '../../../shared/types/insights' + +export function UnloggedTablesCard({ tables }: { tables: UnloggedTable[] }) { + const hasIssues = tables.length > 0 + + return ( +
+
+ + {hasIssues ? '⚠' : '✓'} + + Unlogged tables +
+ + {!hasIssues ? ( +

No unlogged tables found.

+ ) : ( +
    + {tables.map((t) => ( +
  • + {t.table}: {t.explanation} +
  • + ))} +
+ )} +
+ ) +} diff --git a/web/src/features/lock-graph/components/ClusterHulls.tsx b/web/src/features/lock-graph/components/ClusterHulls.tsx index 42bd2b7..a429cfd 100644 --- a/web/src/features/lock-graph/components/ClusterHulls.tsx +++ b/web/src/features/lock-graph/components/ClusterHulls.tsx @@ -1,5 +1,4 @@ import { useViewport } from '@xyflow/react' -import { line, curveCatmullRomClosed } from 'd3-shape' import type { ClusterHull } from '../../../shared/utils/graph' const HULL_COLORS = [ @@ -16,21 +15,76 @@ const HULL_COLORS = [ // aligns the hull shape with where nodes visually appear (their centers). const NODE_CENTER_OFFSET = 22 -const hullLine = line<{ x: number; y: number }>() - .x((p) => p.x) - .y((p) => p.y) - .curve(curveCatmullRomClosed) +// How far (in px) the rounding cuts into each corner, capped at half the +// adjacent edge's length so it never overreaches on a small polygon. Sized +// generously (much larger than the first attempt at this) so a large, +// many-cornered cluster still reads as one smooth, rounded blob rather +// than a faceted polygon with only its very tips rounded off. +const CORNER_ROUNDING_RADIUS = 50 + +/** + * Builds a closed SVG path that rounds each corner of the polygon + * independently — a quadratic Bezier through the corner itself, anchored + * at points a fixed radius along each adjacent edge — rather than fitting + * one smooth curve through every point at once (what a d3 curve like + * curveCatmullRomClosed or curveCardinalClosed does). + * + * That global-fit approach is what previously caused unpredictable + * failures depending on point count and arrangement: with few, unevenly + * spaced, or near-collinear points (e.g. a 3-node cluster roughly in a + * line), a Catmull-Rom/Cardinal spline's tangent estimation can overshoot + * far beyond the polygon, or (at high tension) pull in so far it no + * longer reaches some of the original points at all. Rounding each corner + * locally sidesteps both failure modes by construction: a quadratic + * Bezier with the corner itself as control point is mathematically + * guaranteed to stay within the triangle formed by the corner and its two + * rounding-radius anchor points, so the rendered shape can never extend + * past the original polygon and can never fail to reach within + * CORNER_ROUNDING_RADIUS of every original point — regardless of how many + * points there are or how they're arranged. + */ +function roundedPolygonPath(points: { x: number; y: number }[], radius: number): string { + const n = points.length + if (n < 3) return '' + + const segments: string[] = [] + + for (let i = 0; i < n; i++) { + const prev = points[(i - 1 + n) % n] + const cur = points[i] + const next = points[(i + 1) % n] + + const toPrev = { x: prev.x - cur.x, y: prev.y - cur.y } + const toNext = { x: next.x - cur.x, y: next.y - cur.y } + const distPrev = Math.hypot(toPrev.x, toPrev.y) || 1 + const distNext = Math.hypot(toNext.x, toNext.y) || 1 + const rPrev = Math.min(radius, distPrev / 2) + const rNext = Math.min(radius, distNext / 2) + + const pre = { x: cur.x + (toPrev.x / distPrev) * rPrev, y: cur.y + (toPrev.y / distPrev) * rPrev } + const post = { x: cur.x + (toNext.x / distNext) * rNext, y: cur.y + (toNext.y / distNext) * rNext } + + if (i === 0) { + segments.push(`M ${pre.x} ${pre.y}`) + } else { + segments.push(`L ${pre.x} ${pre.y}`) + } + segments.push(`Q ${cur.x} ${cur.y} ${post.x} ${post.y}`) + } + + segments.push('Z') + return segments.join(' ') +} interface ClusterHullsProps { hulls: ClusterHull[] } /** - * Renders each cluster's convex hull as a smooth, rounded shaded region - * behind the graph's nodes/edges (a Catmull-Rom closed curve through the - * hull points, rather than a sharp-cornered polygon), manually kept in - * sync with React Flow's pan/zoom via useViewport() — this component must - * be rendered as a child of to access that context. + * Renders each cluster's convex hull as a shaded region with rounded + * corners behind the graph's nodes/edges, manually kept in sync with + * React Flow's pan/zoom via useViewport() — this component must be + * rendered as a child of to access that context. */ export function ClusterHulls({ hulls }: ClusterHullsProps) { const { x, y, zoom } = useViewport() @@ -44,7 +98,7 @@ export function ClusterHulls({ hulls }: ClusterHullsProps) { x: p.x + NODE_CENTER_OFFSET, y: p.y + NODE_CENTER_OFFSET, })) - const path = hullLine(centeredPoints) + const path = roundedPolygonPath(centeredPoints, CORNER_ROUNDING_RADIUS) if (!path) return null diff --git a/web/src/shared/api/MonitoringStreamProvider.tsx b/web/src/shared/api/MonitoringStreamProvider.tsx index 73ad48e..a3b917d 100644 --- a/web/src/shared/api/MonitoringStreamProvider.tsx +++ b/web/src/shared/api/MonitoringStreamProvider.tsx @@ -9,6 +9,7 @@ const EMPTY_STATS: DatabaseActivityStats = { commitsPerSecond: 0, rollbacksPerSecond: 0, cacheHitRatio: 0, + tempBytesPerSecond: 0, measuredAt: '', } diff --git a/web/src/shared/api/controlClient.ts b/web/src/shared/api/controlClient.ts index b73cf44..f4e3ecd 100644 --- a/web/src/shared/api/controlClient.ts +++ b/web/src/shared/api/controlClient.ts @@ -14,16 +14,13 @@ export function stopMonitoring(): Promise { return postJSON('/api/v1/monitor/stop') } -export function startRecording(minutes: number): Promise { - return postJSON(`/api/v1/record/start?minutes=${minutes}`) -} - -export function stopRecording(): Promise { - return postJSON('/api/v1/record/stop') -} +export type HistoryWindow = '1h' | '3h' | '6h' | '12h' | '24h' -export async function downloadHistory(): Promise { - const response = await fetch('/api/v1/history') +// Recording is no longer a separate start/stop action — it happens +// automatically in the background whenever monitoring is active. This just +// downloads whatever's already been recorded for the requested window. +export async function downloadHistory(window: HistoryWindow): Promise { + const response = await fetch(`/api/v1/history?window=${window}`) if (!response.ok) { throw new Error(`Failed to fetch history: ${response.status}`) } diff --git a/web/src/shared/types/insights.ts b/web/src/shared/types/insights.ts index 7d02fdb..863b5fe 100644 --- a/web/src/shared/types/insights.ts +++ b/web/src/shared/types/insights.ts @@ -72,6 +72,10 @@ export interface Insights { replicationLagWarnings: ReplicationLagWarning[] physicalIOEnabled: boolean physicalIOHotspots: PhysicalIOHotspot[] + preparedTransactionWarnings: PreparedTransactionWarning[] + replicationSlotWarnings: ReplicationSlotWarning[] + longRunningQueryWarnings: LongRunningQueryWarning[] + unloggedTables: UnloggedTable[] } export interface TableSize { @@ -148,4 +152,33 @@ export interface PhysicalIOHotspot { userTimeMs: number systemTimeMs: number explanation: string +} + +export interface PreparedTransactionWarning { + gid: string + database: string + owner: string + ageSeconds: number + explanation: string +} + +export interface ReplicationSlotWarning { + slotName: string + active: boolean + retainedBytes: number + explanation: string +} + +export interface LongRunningQueryWarning { + pid: number + user: string + applicationName: string + query: string + runningSeconds: number + explanation: string +} + +export interface UnloggedTable { + table: string + explanation: string } \ No newline at end of file diff --git a/web/src/shared/types/session.ts b/web/src/shared/types/session.ts index f2675c6..dff79df 100644 --- a/web/src/shared/types/session.ts +++ b/web/src/shared/types/session.ts @@ -42,5 +42,6 @@ export interface DatabaseActivityStats { commitsPerSecond: number rollbacksPerSecond: number cacheHitRatio: number + tempBytesPerSecond: number measuredAt: string } \ No newline at end of file