Files
supabase/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.ts
Joshen Lim ded5bc525b Joshen/fe 4000 activity table to show queries which are blockers (#48383)
## Context

One for Database Connections - allow a user to view the root blocking
queries

Adds an additional filter button here that toggles the view
<img width="738" height="142" alt="image"
src="https://github.com/user-attachments/assets/9fea17ba-c6f6-419d-8847-47dba67fc00a"
/>

When toggled, will render a list of the _root_ blocking queries - these
are queries that are at the end of the blocking chain (or otherwise the
problematic ones causing other queries to be blocked)
<img width="964" height="420" alt="image"
src="https://github.com/user-attachments/assets/5300f523-6abe-49b6-92d0-7e16bbddd291"
/>

Within this view - you can expand the row to view the blocking chain
<img width="950" height="335" alt="image"
src="https://github.com/user-attachments/assets/bb07095a-3841-4db6-8959-ac2bb264ebf6"
/>

## Other changes involved
- Realised that "Top blocker" overview metric card logic is incorrect
- Was previously naively checking the length of the `blocked_by` array,
but it should be consider the nested chain length instead, so this PR
fixes that
<img width="364" height="108" alt="image"
src="https://github.com/user-attachments/assets/89beccef-f6f0-43d1-9dcf-fc35958b09e5"
/>
- Clicking the PID if highlighted on a metric card will not scroll to
the PID if it's already selected. This PR fixes that

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

* **New Features**
* Added a **Root blockers** view to highlight sessions that block
others, with expandable blocking chains revealing related waiting
activity.
* **Bug Fixes**
* Updated blocking metrics to use **transitive** blocker counts and
improved cycle protection and behavior when activity records are
missing.
* The blockers view now consistently affects state/application/role
quantities, and **reset filters** clears the view.
* **Refactor / UI**
* Improved the sessions table with grouped/nested rows, clearer waiting
indicators, and more consistent expand/collapse behavior.
* **Tests**
* Expanded coverage for blocking/waiting chain traversal and branching
scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-29 16:55:28 +08:00

196 lines
6.9 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 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
}