Skip to content

perf(server): drop three unused indexes on the sessions table - #6884

Open
otavio wants to merge 1 commit into
perf/device-heartbeat-hot-updatesfrom
perf/drop-unused-session-indexes
Open

perf(server): drop three unused indexes on the sessions table#6884
otavio wants to merge 1 commit into
perf/device-heartbeat-hot-updatesfrom
perf/drop-unused-session-indexes

Conversation

@otavio

@otavio otavio commented Aug 11, 2026

Copy link
Copy Markdown
Member

Stacked on #6883 — based on perf/device-heartbeat-hot-updates so migration numbering
cannot collide. Merge #6883 first; this then targets master on its own.

Migration 020 drops three indexes on sessions that back no filter, no sort and no constraint,
while costing an index insert on every session row written. Over 66 days of production counters
they served 2, 0 and 0 scans, against 204,400 on sessions_started_at_idx.

Index Scans / 66 d Size
sessions_username_idx 2 8 MB
sessions_type_idx 0 8 MB
sessions_closed_started_idx 0 42 MB

Unlike the devices pair in #6883 there is no read-path trade to weigh here: nothing queries
these columns, so nothing gets slower. Dropping an index reclaims its space immediately, so no
repack is needed either.

username and type are unreachable, not merely unused

Verified against the source in both shellhub and cloud rather than trusted from counters:

  • The session list accepts exactly three filter fields — device_uid, closed, active
    (SessionFilterFields) — and rejects anything else at the route before it reaches SQL.
  • The sort is not user-selectable at all: ListSessions hardcodes
    Sorter{By: "started_at", Order: desc, Tiebreak: "id"}. There is no SessionSortFields.
  • cloud adds no session filters or sorts of its own.
  • No WHERE/ORDER BY on either column exists in either repo. The type = ? predicates that do
    exist are all on session_events, a different table, already served by its session_id/seat
    indexes.

sessions_closed_started_idx is redundant, which is why 0 scans needed explaining

This one does have a consumer, so the counter alone would have been misleading. The
recording-conversion worker runs
WHERE recorded AND closed AND converted = ? ORDER BY started_at DESC LIMIT 10 — close to a
textbook match for (closed, started_at). The query runs on every cron tick; the index is simply
never chosen.

The reason is that its leading column is a near-constant: a session is closed for all but the
minutes it is live, so (closed, started_at) is a strictly fatter duplicate of
sessions_started_at_idx — 42 MB against 26 MB — offering the planner nothing extra for either
the filter or the ORDER BY. The smaller index wins on cost every time, which is exactly what the
204,400-vs-0 split shows.

If that worker ever shows up as a cost, the index it wants is a partial one over the backlog
alone, not this:

CREATE INDEX sessions_pending_conversion_idx ON sessions (started_at DESC)
  WHERE recorded AND closed AND NOT converted;

A few pages instead of 42 MB. Not included here — that would be speculation.

sessions_namespace_id_idx stays

Despite only 63 scans. Deleting a namespace cascades to sessions, and without this index that is
a sequential scan over a 1.19 M-row table. The cascade argument does not depend on idx_scan at
all, so it holds regardless of how shellhub-io/team#200 resolves.

Testing

Full pg store suite green against a schema built from 001 through 020, and verified
end-to-end through bun's runner on the dev stack: 020 applies at boot and leaves sessions with
sessions_pkey, sessions_namespace_id_idx, sessions_device_id_idx and
sessions_started_at_idx.

Fixes shellhub-io/team#199.

@otavio

otavio commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Follow-up #6886 is stacked on this branch — it enables pg_stat_statements, pgstattuple and pg_buffercache as migration 021. Merge order: #6883#6884#6886.

sessions_username_idx, sessions_type_idx and sessions_closed_started_idx back no filter, no sort
and no constraint, while costing an index insert on every session row written. Over 66 days of
production counters they served 2, 0 and 0 scans, against 204,400 on sessions_started_at_idx.

username and type are unreachable by construction rather than merely unused: the session list
accepts exactly three filter fields (device_uid, closed, active) and rejects anything else at the
route, its sort is hardcoded to started_at with no user-selectable alternative, and cloud adds no
session filters of its own. The type predicates that do exist are all on session_events, a
different table, already served by its session_id and seat indexes.

sessions_closed_started_idx is redundant rather than unused, which is why its 0 scans needed
explaining before trusting them: the recording-conversion worker runs WHERE closed AND recorded
AND NOT converted ORDER BY started_at DESC, which looks tailor-made for (closed, started_at). But
closed is a near-constant — a session is closed for all but the minutes it is live — so the index
is a strictly fatter duplicate of sessions_started_at_idx, 42 MB against 26 MB, and loses to it on
cost for every shape that exists. If that worker ever shows up as a cost, what it wants is a
partial index over the unconverted backlog alone.

sessions_namespace_id_idx stays despite its 63 scans: deleting a namespace cascades to sessions,
and without it that is a sequential scan over 1.19 M rows.

Unlike the devices pair in 017 there is no read-path trade here, so nothing gets slower.

Fixes: shellhub-io/team#199
@gustavosbarreto
gustavosbarreto force-pushed the perf/drop-unused-session-indexes branch from f9be886 to 84ff059 Compare August 12, 2026 13:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants