Files
supabase/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.ts
Joshen Lim 60be899fdb Selecting a PID from the overview card should clear filters if not visible in the UI (#49135)
## Context

For Database Connections - the PIDs on the overview cards are selectable
such that clicking on them should scroll the browser down to where the
row is.

However, if the selected PID isn't rendered due to the applied filters,
clicking on it will seemingly do nothing. Changes here hence opt to
remove all filters then scroll to the selected PID into view, so that
users can always quickly find which PID the overview card is
referencing.

Also chucked in some refactors to centralize the management of filters,
and functionality of selecting a PID into their own hooks

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added shared filtering for database activity by state, role,
application, search text, and view.
* Activity filters are now preserved in the URL for easier navigation
and sharing.
* Selecting activity metrics or process IDs now automatically reveals
the relevant activity row.
* Blocker view highlights root activities that are blocking other
queries.

* **Bug Fixes**
* Improved selection behavior when the chosen activity is hidden by
active filters.

* **Tests**
* Added coverage for individual, combined, case-insensitive, and
blocker-specific filtering scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-17 17:45:02 +08:00

233 lines
8.5 KiB
TypeScript

import dayjs from 'dayjs'
import {
WARN_DURATION_ACTIVE_QUERY,
WARN_DURATION_BLOCKED,
WARN_DURATION_IDLE_TXN,
WARN_TOP_BLOCKER,
} from './DatabaseConnections.constants'
import { type DatabaseActivity } from '@/data/database/activity-query'
const LONG_RUNNING_STATES: (DatabaseActivity['state'] | undefined)[] = [
'active',
'idle in transaction',
'idle in transaction (aborted)',
]
type ActivityWithDuration = { activity: DatabaseActivity; duration: number }
export type ConnectionMetrics = {
activeQueries: DatabaseActivity[]
blockedQueries: DatabaseActivity[]
warnBlockedQueries: boolean
longestBlockedQuery: ActivityWithDuration | null
idleInTransactionQueries: DatabaseActivity[]
longestRunningQuery: ActivityWithDuration | null
warnLongestRunningQuery: boolean
queryBlockingTheMostQueries: { activity: DatabaseActivity; count: number } | null
warnTopBlocker: boolean
}
export const getDuration = (activity: DatabaseActivity) => {
const { state } = activity
if (state === 'active' && activity.query_start) {
return dayjs().utc().diff(dayjs(activity.query_start).utc(), 'second')
}
if (state === 'idle' && activity.state_change) {
return dayjs().utc().diff(dayjs(activity.state_change).utc(), 'second')
}
if (
(state === 'idle in transaction' || state === 'idle in transaction (aborted)') &&
activity.transaction_start
) {
return dayjs().utc().diff(dayjs(activity.transaction_start).utc(), 'second')
}
return null
}
export const getBadgeVariant = (activity: DatabaseActivity) => {
const { state } = activity
if (state === 'active') return 'success'
if (state === 'idle in transaction' || state === 'idle in transaction (aborted)') return 'warning'
return 'default'
}
// Resolves the timestamp that duration should be measured from for a given state -
// query_start while actively running, transaction_start while idle in transaction.
export const getActivityStart = (activity: DatabaseActivity) => {
if (activity.state === 'active') return activity.query_start
if (
activity.state === 'idle in transaction' ||
activity.state === 'idle in transaction (aborted)'
) {
return activity.transaction_start
}
return null
}
// Finds the activity with the largest duration-since-start among the given activities (e.g. the
// longest-blocked query, or the longest-running query).
const findLongestRunning = (
activities: DatabaseActivity[],
now: dayjs.Dayjs
): ActivityWithDuration | null =>
activities.reduce<ActivityWithDuration | null>((longest, activity) => {
const start = getActivityStart(activity)
if (!start) return longest
const duration = Math.max(now.diff(dayjs(start).utc(), 'second'), 0)
return longest === null || duration > longest.duration ? { activity, duration } : longest
}, null)
// Counts every activity transitively blocked by pid - not just direct waiters, but their waiters
// in turn - so a long chain outweighs several short ones. Traverses breadth-first over a single
// counted set so a waiter reachable through more than one blocker (a "diamond") is still only
// counted once.
const countTransitivelyBlocked = (pid: number, activities: DatabaseActivity[]): number => {
const counted = new Set<number>()
const queue = [pid]
while (queue.length > 0) {
const currentPid = queue.shift()!
const directWaiters = activities.filter((x) => x.blocked_by.includes(currentPid))
for (const waiter of directWaiters) {
if (counted.has(waiter.pid)) continue
counted.add(waiter.pid)
queue.push(waiter.pid)
}
}
return counted.size
}
// Derives the Overview page's connection/activity metrics (and their warning thresholds) from the
// raw pg_stat_activity rows, so the logic can be unit tested independently of the component.
export const getConnectionMetrics = (activities: DatabaseActivity[]): ConnectionMetrics => {
// Computed once so every metric below is measured against the same instant, rather than each
// drifting slightly against the others.
const now = dayjs().utc()
const activeQueries = activities.filter((x) => x.state === 'active')
const blockedQueries = activities.filter((x) => x.blocked_by.length > 0)
const warnBlockedQueries = blockedQueries.some((activity) => {
const start = getActivityStart(activity)
if (!start) return false
return now.diff(dayjs(start).utc(), 'second') >= WARN_DURATION_BLOCKED
})
const longestBlockedQuery = findLongestRunning(blockedQueries, now)
const idleInTransactionQueries = activities.filter((x) => {
const isIdleInTransaction =
x.state === 'idle in transaction' || x.state === 'idle in transaction (aborted)'
if (!isIdleInTransaction || !x.transaction_start) return false
return now.diff(dayjs(x.transaction_start).utc(), 'second') >= WARN_DURATION_IDLE_TXN
})
const longestRunningQuery = findLongestRunning(
activities.filter((x) => LONG_RUNNING_STATES.includes(x.state)),
now
)
const warnLongestRunningQuery =
(longestRunningQuery?.activity.state === 'active' &&
longestRunningQuery.duration >= WARN_DURATION_ACTIVE_QUERY) ||
((longestRunningQuery?.activity.state === 'idle in transaction' ||
longestRunningQuery?.activity.state === 'idle in transaction (aborted)') &&
longestRunningQuery.duration >= WARN_DURATION_IDLE_TXN)
const queryBlockingTheMostQueries = activities.reduce<{
activity: DatabaseActivity
count: number
} | null>((mostBlocking, activity) => {
const count = countTransitivelyBlocked(activity.pid, activities)
if (count === 0) return mostBlocking
if (mostBlocking && count <= mostBlocking.count) return mostBlocking
return { activity, count }
}, null)
const warnTopBlocker = (queryBlockingTheMostQueries?.count ?? 0) >= WARN_TOP_BLOCKER
return {
activeQueries,
blockedQueries,
warnBlockedQueries,
longestBlockedQuery,
idleInTransactionQueries,
longestRunningQuery,
warnLongestRunningQuery,
queryBlockingTheMostQueries,
warnTopBlocker,
}
}
export type ActivityFilters = {
search: string
states: string[]
applications: string[]
roles: string[]
view: string
}
// Applies the Sessions table's filters to the raw pg_stat_activity rows. Shared so that anything
// selecting a pid (e.g. the Overview metric cards) can check whether that pid would actually be
// visible under the current filters, without duplicating this predicate.
export const filterActivities = (
activities: DatabaseActivity[],
filters: ActivityFilters
): DatabaseActivity[] => {
const { search, states, applications, roles, view } = filters
// Pids referenced in some other activity's blocked_by - i.e. they are blocking something
const blockingPids = new Set(activities.flatMap((x) => x.blocked_by))
return activities.filter((activity) => {
const matchesState =
states.length === 0 || (activity.state !== null && states.includes(activity.state))
const matchesRole = roles.length === 0 || roles.includes(activity.role_name)
const matchesApplication =
applications.length === 0 || applications.includes(activity.application_name)
// In the blocked view, only show root blockers - activities blocking others while not
// themselves blocked. Everything they block is shown nested under them instead.
const matchesView =
view !== 'blockers' || (activity.blocked_by.length === 0 && blockingPids.has(activity.pid))
const matchesSearch =
!search || (activity.query?.toLowerCase().includes(search.toLowerCase()) ?? false)
return matchesState && matchesRole && matchesApplication && matchesView && matchesSearch
})
}
export const getBlockChain = (pid: number, activities: DatabaseActivity[]) => {
const chain = [pid]
const visited = new Set([pid])
let current = activities.find((x) => x.pid === pid)
while (current && current.blocked_by.length > 0) {
const nextPid = current.blocked_by[0]
if (visited.has(nextPid)) break
chain.push(nextPid)
visited.add(nextPid)
current = activities.find((x) => x.pid === nextPid)
}
return chain
}
// Walks the opposite direction of getBlockChain: starting from a root blocker
// (blocked_by.length === 0), finds the chain of pids waiting on it, nearest first
export const getBlockingChain = (rootPid: number, activities: DatabaseActivity[]) => {
const chain: number[] = []
const visited = new Set([rootPid])
let currentPid = rootPid
while (true) {
const next = activities.find((x) => !visited.has(x.pid) && x.blocked_by.includes(currentPid))
if (!next) break
chain.push(next.pid)
visited.add(next.pid)
currentPid = next.pid
}
return chain
}