This document summarizes the implementation of the pgwatch reaper goroutine consolidation, which replaces the previous one-goroutine-per-(source Γ metric) architecture with a one-goroutine-per-source model using pgx Batch queries.
What Changed
Architecture: Before vs After
Aspect
Before
After
Goroutine model
1 goroutine per (source Γ metric)
1 goroutine per source
SQL execution
1 Query() call per metric per tick
1 SendBatch() call per source per tick
Query protocol
Individual queries, each a network round-trip
pgx pipeline protocol β multiple queries in one round-trip
Cancel granularity
Per source€€€metric key
Per source name
Config hot-reload
Cancel + re-spawn per metric goroutine
UpdateSchedules() on existing SourceReaper
Goroutine Reduction
Scenario
Before
After
Reduction
10 sources Γ exhaustive (32 metrics)
320
10
97%
50 sources Γ exhaustive
1,600
50
97%
1 source Γ basic (4 metrics)
4
1
75%
Network Round-Trip Reduction
With the exhaustive preset at 60-second alignment, ~12 SQL metrics are due simultaneously.
Before
After
Reduction
12 separate Query() calls
1 SendBatch() call
~92%
At peak alignment (t = 7200s, all 32 metrics due): 32 β 1 round-trip = 97% reduction.
Implementation Phases
Phase 1: Core Infrastructure β
Added SendBatch(ctx, *pgx.Batch) pgx.BatchResults to PgxPoolIface interface
Created SourceReaper struct with per-source state: metric schedules, tick interval, connection
Implemented GCD() / GCDSlice() for computing tick interval from metric intervals
Implemented isDue() check with zero-value = "never fetched" semantics
executeBatch(): Builds pgx.Batch from due metrics, sends in one round-trip, dispatches results per-metric
Preserves: instance-level caching, primary/standby filtering, AddSysinfoToMeasurements, server restart detection
fetchSequentialMetric(): Fallback for non-Postgres sources (pgbouncer, pgpool) using simple protocol
fetchOSMetric(), fetchSpecialMetric(): Handle gopsutil and special metrics inline
BatchQueryMeasurements(): Standalone batch helper with deterministic (sorted) key ordering
Phase 3: Main Loop Integration β
Reap() now spawns go sr.Run(sourceCtx) per source instead of per-metric goroutines
cancelFuncs map simplified from map[string]context.CancelFunc keyed by db€€€metric to keyed by source name
Added sourceReapers map[string]*SourceReaper to Reaper struct
ShutdownOldWorkers() simplified β only checks if source was removed from config
Removed dead reapMetricMeasurements() function (was ~100 lines)
Phase 4: Change Detection Batching β
GetObjectChangesMeasurement() now calls prefetchChangeDetectionData() to batch-fetch all hash queries (sproc_hashes, table_hashes, index_hashes, privilege_changes) in one pgx.Batch
Added Detect*ChangesWithData() variants that accept pre-fetched data, falling back to original methods if nil
Configuration changes (DetectConfigurationChanges) remain unbatched β different Scan() pattern with typed variables
Phase 5: Cleanup & Observability β
New Prometheus metrics in observability.go:
pgwatch_reaper_batch_size (histogram) β queries per batch
pgwatch_reaper_batch_duration_seconds (histogram) β wall-clock time per batch
Standalone batch (Postgres) and sequential (non-Postgres) paths
Integration Tests (testcontainers) β 6 test functions
Test
What it verifies
TestIntegration_BatchQueryMeasurements
4 real SQL queries batched against Postgres 18, all return correct data
TestIntegration_ExecuteBatch
Full executeBatch() path with 2 metric definitions β envelopes arrive
TestIntegration_SourceReaper_RunCollectsMetrics
Run() loop starts, collects 2 metrics within 15s, exits cleanly
TestIntegration_BatchVsSequentialConsistency
Batch and sequential paths return identical results for same query
TestIntegration_BatchEmptySQL
Empty/whitespace SQL queries are silently skipped
TestIntegration_BatchMultipleMetricsSameRoundTrip
10 queries sent in one batch, all 10 return results
Existing Tests β 0 regressions
All 152 test cases in internal/reaper/ pass, including all pre-existing tests for DetectSprocChanges, DetectTableChanges, DetectIndexChanges, DetectPrivilegeChanges, DetectConfigurationChanges, FetchMetric, LoadSources, LoadMetrics, log parser tests, and OS metric tests.
Design Decisions
Decision
Rationale
GCD-based tick loop (Option A)
Zero external dependencies, natural fit with context cancellation, simple reasoning
No external scheduler (gocron)
gocron v2 uses one goroutine per job, defeating the consolidation purpose
pgwatch_reaper_metric_fetch_total with source/status labels enables per-source error rate alerting
pgwatch_reaper_active_source_reapers gauge shows current source count
Future Enhancements
Overflow workers (Option D): Offload known-slow metrics (e.g., table_bloat_approx_summary_sql) to a separate goroutine if they exceed a time threshold, preventing them from blocking the batch
Adaptive tick interval: Dynamically adjust tick interval based on observed query latency
Per-metric batch timeout: Use SET LOCAL statement_timeout within the batch for metrics with StatementTimeoutSeconds configured
Batch configuration changes: Batch the DetectConfigurationChanges hash queries (currently excluded due to different Scan() pattern)