fix(mock): backfill the metrics store in mock mode so reports get real history
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run

Mock mode seeded rich in-memory chart history but passed a nil store to the
seeder and the live tick, so the sqlite metrics store held almost nothing:
vmware/truenas/docker resources got one row per poll from the platform
ingestion paths and mock Proxmox guests got nothing at all (the unified sync
skips every resource in mock mode). Performance reports rendered
"Data Points: 0" for PVE guests and "Samples: 1" elsewhere, with no charts.

Re-enable store seeding behind an explicit PULSE_MOCK_SEED_METRICS_STORE
opt-in that scripts/hot-dev.sh and scripts/toggle-mock.sh export exactly
where they point PULSE_DATA_DIR at the isolated tmp/mock-data dir. Without
the opt-in (a production install flipping PULSE_MOCK_MODE on its real data
dir) the store stays untouched, which is what the old nil guard protected.

Replace the dormant dense seeding policy (every in-memory timestamp written
to both hourly and daily tiers, ~11M rows at current fixture scale) with
tier-correct backfill driven by the deterministic mock.SampleMetric runtime:
daily 30d at 4h spacing, hourly 7d at 2h, minute 24h at 15m, raw left to the
live tick (now also store-connected under the opt-in). Timestamps sit on the
spacing grid and a new Store.MaxTimestampsForTier coverage query fills only
the gap since the previous boot, so restarts neither duplicate rows nor
re-pay the seed: a fresh seed wrote 264,960 rows in 8.4s (71MB) and a
restart wrote only the gap plus fixture series whose mock IDs are not
boot-stable (k8s pod names, ceph FSID, a pre-existing generator defect).

Verified live in mock mode: 30-day PDF reports for a mock PVE VM
(checkout-web-01), a vSphere VM, the TrueNAS host, and a Docker container
now show 1260/1260/1260/540 data points with rendered time-series charts.
This commit is contained in:
rcourtman 2026-06-10 21:25:02 +01:00
parent 8ce834cc59
commit f136b1ef59
8 changed files with 487 additions and 96 deletions

View file

@ -158,6 +158,24 @@ type WriteMetric struct {
Tier Tier
}
// SeriesKey identifies one stored metric series in the normalized form the
// write path persists (lower-cased resource/metric types, trimmed id).
type SeriesKey struct {
ResourceType string
ResourceID string
MetricType string
}
// NormalizedSeriesKey builds the SeriesKey the write path would store for the
// given identifiers, so callers can match MaxTimestampsForTier results.
func NormalizedSeriesKey(resourceType, resourceID, metricType string) SeriesKey {
return SeriesKey{
ResourceType: normalizeMetricResourceType(resourceType),
ResourceID: normalizeMetricIdentifier(resourceID),
MetricType: normalizeMetricType(metricType),
}
}
type maintenanceRequest struct {
run func()
done chan struct{}
@ -1707,6 +1725,33 @@ func (s *Store) getMaxTimestampForTier(tier Tier) (int64, bool) {
return maxTs.Int64, true
}
// MaxTimestampsForTier returns the newest stored timestamp for every series
// present in the given tier. Backfill seeders use it to write only the gap
// between existing coverage and now instead of re-writing whole windows.
func (s *Store) MaxTimestampsForTier(tier Tier) (map[SeriesKey]time.Time, error) {
rows, err := s.db.Query(`
SELECT resource_type, resource_id, metric_type, MAX(timestamp)
FROM metrics
WHERE tier = ?
GROUP BY resource_type, resource_id, metric_type
`, string(tier))
if err != nil {
return nil, fmt.Errorf("failed to query metrics coverage for tier %q: %w", tier, err)
}
defer rows.Close()
coverage := make(map[SeriesKey]time.Time)
for rows.Next() {
var key SeriesKey
var ts int64
if err := rows.Scan(&key.ResourceType, &key.ResourceID, &key.MetricType, &ts); err != nil {
return nil, fmt.Errorf("failed to scan metrics coverage row: %w", err)
}
coverage[key] = time.Unix(ts, 0).UTC()
}
return coverage, rows.Err()
}
// runRetention deletes data older than retention period
func (s *Store) runRetention() {
start := time.Now()

View file

@ -0,0 +1,49 @@
package metrics
import (
"testing"
"time"
)
func TestMaxTimestampsForTier(t *testing.T) {
store, err := NewStore(DefaultConfig(t.TempDir()))
if err != nil {
t.Fatalf("failed to create metrics store: %v", err)
}
defer store.Close()
base := time.Now().UTC().Truncate(time.Hour)
store.WriteBatchSync([]WriteMetric{
{ResourceType: "vm", ResourceID: "vm-1", MetricType: "cpu", Value: 1, Timestamp: base.Add(-2 * time.Hour), Tier: TierHourly},
{ResourceType: "vm", ResourceID: "vm-1", MetricType: "cpu", Value: 2, Timestamp: base, Tier: TierHourly},
{ResourceType: " VM ", ResourceID: " vm-2 ", MetricType: " CPU ", Value: 3, Timestamp: base.Add(-time.Hour), Tier: TierHourly},
{ResourceType: "vm", ResourceID: "vm-1", MetricType: "cpu", Value: 4, Timestamp: base.Add(time.Hour), Tier: TierRaw},
})
coverage, err := store.MaxTimestampsForTier(TierHourly)
if err != nil {
t.Fatalf("failed to read hourly coverage: %v", err)
}
if len(coverage) != 2 {
t.Fatalf("expected 2 hourly series, got %d: %v", len(coverage), coverage)
}
if got := coverage[NormalizedSeriesKey("vm", "vm-1", "cpu")]; !got.Equal(base) {
t.Fatalf("expected vm-1 hourly coverage at %s, got %s", base, got)
}
// Coverage keys are stored normalized, so messy caller identifiers must
// resolve through NormalizedSeriesKey.
if got := coverage[NormalizedSeriesKey(" VM ", " vm-2 ", " CPU ")]; !got.Equal(base.Add(-time.Hour)) {
t.Fatalf("expected normalized vm-2 hourly coverage at %s, got %s", base.Add(-time.Hour), got)
}
rawCoverage, err := store.MaxTimestampsForTier(TierRaw)
if err != nil {
t.Fatalf("failed to read raw coverage: %v", err)
}
if len(rawCoverage) != 1 {
t.Fatalf("expected 1 raw series, got %d: %v", len(rawCoverage), rawCoverage)
}
if got := rawCoverage[NormalizedSeriesKey("vm", "vm-1", "cpu")]; !got.Equal(base.Add(time.Hour)) {
t.Fatalf("expected vm-1 raw coverage at %s, got %s", base.Add(time.Hour), got)
}
}