Skip to content

The store-metrics latest read sorts all 400 days of collect.store_metrics on every call: 7.8 s at full retention #3934

Closed

Description

@erikdarlingdata

Summary

DarlingStoreMetricsReader.StoreMetricsLatestSql takes each store object's newest collect.store_metrics row with a DISTINCT ON (object_kind, object_name) ... ORDER BY object_kind, object_name, metric_time DESC over the whole table. The only index is idx_store_metrics_time (metric_time), so every call sorts every retained row. The table keeps 400 days of hourly sweeps at about 250 objects a sweep, so the sort grows linearly for over a year after a store is created.

Found while fixing #3903 (payload size). The reads were out of that fix's scope, and changing them needs an index, which needs a migration.

Measured

A CI-sized rig (PostgreSQL 18.4, TimescaleDB 2.28.1, work_mem 4 MB) with store_metrics seeded to full retention: 400 days × 24 sweeps × 251 objects = 2,409,600 rows, 236 MB. Each read ran under EXPLAIN (ANALYZE, BUFFERS):

read today plan
latest per object (every get_store_metrics call, and the hourly TOAST-slack self-alert through GetLatestAsync) 7,772 ms parallel seq scan of 2.4M rows, external merge sort spilling ~180 MB
daily series, 30-day default window 1,176 ms index scan of 180,720 rows, external merge 15 MB
daily series, days_back = 400 8,350 ms parallel seq scan, external merge ~200 MB

For comparison, DARLING01 today (83,355 rows, 17 days) runs the latest read in 225 ms and the 30-day daily read in 504 ms. So the cost is a function of store AGE, not fleet size, and it arrives gradually.

Candidate fix, measured on the same seed

An index on (object_kind, object_name, metric_time DESC) plus a skip-scan form of the latest read: a recursive CTE walks the distinct (kind, name) pairs and takes one LIMIT 1 row per pair. The latest read goes from 7,772 ms to 15 ms with identical rows (251). It needs a migration rung for the index, so the rung number has to be assigned. The daily read could use the same index per object, or stay on the time index with a larger work_mem for that statement.

The alternative with no migration is a semantic change: bound the latest read to the recent sweeps (metric_time >= max(metric_time) - N days). That drops long-gone objects from the inventory's stale-row count, which is arguably more correct but is a behavior change, so it needs a ruling.

Not urgent

Every figure above is under the 20 s MCP read deadline, and a store reaches full retention only after 400 days. But the latest read runs on every get_store_metrics call and on the hourly self-alert, and it only grows.

Activity

  1. erikdarlingdata commented on Sep 22, 2026

    @erikdarlingdata
    OwnerAuthor

    Implementation note: this probably needs no migration rung. A results-invariant index like (object_kind, object_name, metric_time DESC) is exactly what the store-object convergence registry's Tuning stage exists for (#3817). It creates the composer's covering indexes idempotently at every start and hourly, on every store shape (#3913), and that is how every recent performance index has landed. Add it there, pin it in StoreObjectConvergenceTests, and keep the 7.8 s to 15 ms measurement as the PR's evidence.

  2. added
    in-progressActively being worked by a local session or its agents (PR open or in flight)
    on Sep 23, 2026
  3. erikdarlingdata commented on Sep 23, 2026

    @erikdarlingdata
    OwnerAuthor

    Closed by the watcher: delivered in PR #3998, merged to dev.

  4. removed
    in-progressActively being worked by a local session or its agents (PR open or in flight)
    on Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions