Files
supabase/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.test.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

334 lines
11 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getBlockChain, getBlockingChain, getConnectionMetrics } from './DatabaseConnections.utils'
import { type DatabaseActivity } from '@/data/database/activity-query'
const NOW = '2024-01-15T12:00:00Z'
const secondsAgo = (seconds: number) =>
new Date(new Date(NOW).getTime() - seconds * 1000).toISOString()
// `DatabaseActivity` intersects a discriminated `WaitEvent` union, so `Partial<DatabaseActivity>`
// doesn't distribute cleanly over it - scope overrides to the plain fields tests actually vary.
type ActivityOverrides = Partial<
Pick<
DatabaseActivity,
| 'pid'
| 'role_name'
| 'application_name'
| 'blocked_by'
| 'query'
| 'query_start'
| 'transaction_start'
| 'state_change'
| 'state'
>
>
const activity = (overrides: ActivityOverrides = {}): DatabaseActivity => ({
pid: 1,
role_name: 'postgres',
application_name: 'test',
blocked_by: [],
query: 'select 1',
query_start: null,
transaction_start: null,
state_change: null,
state: 'active',
wait_event_type: null,
wait_event: null,
...overrides,
})
describe('getConnectionMetrics', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(NOW))
})
afterEach(() => {
vi.useRealTimers()
})
it('returns empty/null metrics for no activity', () => {
const metrics = getConnectionMetrics([])
expect(metrics.activeQueries).toEqual([])
expect(metrics.blockedQueries).toEqual([])
expect(metrics.warnBlockedQueries).toBe(false)
expect(metrics.longestBlockedQuery).toBe(null)
expect(metrics.idleInTransactionQueries).toEqual([])
expect(metrics.longestRunningQuery).toBe(null)
expect(metrics.warnLongestRunningQuery).toBe(false)
expect(metrics.queryBlockingTheMostQueries).toBe(null)
expect(metrics.warnTopBlocker).toBe(false)
})
it('counts active queries', () => {
const activities = [
activity({ pid: 1, state: 'active' }),
activity({ pid: 2, state: 'idle' }),
activity({ pid: 3, state: 'active' }),
]
expect(getConnectionMetrics(activities).activeQueries).toHaveLength(2)
})
describe('blocked queries', () => {
it('collects queries that are blocked by another pid', () => {
const activities = [
activity({ pid: 1, blocked_by: [2] }),
activity({ pid: 2, blocked_by: [] }),
]
const { blockedQueries } = getConnectionMetrics(activities)
expect(blockedQueries.map((a) => a.pid)).toEqual([1])
})
it('does not warn when blocked under the threshold (10s)', () => {
const activities = [
activity({ pid: 1, state: 'active', blocked_by: [2], query_start: secondsAgo(5) }),
]
expect(getConnectionMetrics(activities).warnBlockedQueries).toBe(false)
})
it('warns once a blocked query crosses the threshold (10s)', () => {
const activities = [
activity({ pid: 1, state: 'active', blocked_by: [2], query_start: secondsAgo(11) }),
]
expect(getConnectionMetrics(activities).warnBlockedQueries).toBe(true)
})
it('picks the longest-blocked query', () => {
const activities = [
activity({ pid: 1, state: 'active', blocked_by: [3], query_start: secondsAgo(5) }),
activity({ pid: 2, state: 'active', blocked_by: [3], query_start: secondsAgo(20) }),
]
const { longestBlockedQuery } = getConnectionMetrics(activities)
expect(longestBlockedQuery?.activity.pid).toBe(2)
expect(longestBlockedQuery?.duration).toBe(20)
})
})
describe('idle in transaction', () => {
it('does not flag idle-in-transaction queries under the threshold (10s)', () => {
const activities = [
activity({ pid: 1, state: 'idle in transaction', transaction_start: secondsAgo(5) }),
]
expect(getConnectionMetrics(activities).idleInTransactionQueries).toEqual([])
})
it('flags idle-in-transaction (and aborted) queries over the threshold (10s)', () => {
const activities = [
activity({ pid: 1, state: 'idle in transaction', transaction_start: secondsAgo(11) }),
activity({
pid: 2,
state: 'idle in transaction (aborted)',
transaction_start: secondsAgo(11),
}),
activity({ pid: 3, state: 'active', query_start: secondsAgo(11) }),
]
const { idleInTransactionQueries } = getConnectionMetrics(activities)
expect(idleInTransactionQueries.map((a) => a.pid)).toEqual([1, 2])
})
})
describe('longest running query', () => {
it('ignores states outside active/idle-in-transaction', () => {
const activities = [activity({ pid: 1, state: 'idle', state_change: secondsAgo(1000) })]
expect(getConnectionMetrics(activities).longestRunningQuery).toBe(null)
})
it('picks the longest-running query among active/idle-in-transaction states', () => {
const activities = [
activity({ pid: 1, state: 'active', query_start: secondsAgo(5) }),
activity({
pid: 2,
state: 'idle in transaction',
transaction_start: secondsAgo(15),
}),
]
const { longestRunningQuery } = getConnectionMetrics(activities)
expect(longestRunningQuery?.activity.pid).toBe(2)
expect(longestRunningQuery?.duration).toBe(15)
})
it('warns for an active query past the active threshold (30s)', () => {
const activities = [activity({ pid: 1, state: 'active', query_start: secondsAgo(31) })]
expect(getConnectionMetrics(activities).warnLongestRunningQuery).toBe(true)
})
it('does not warn for an active query under the active threshold (30s)', () => {
const activities = [activity({ pid: 1, state: 'active', query_start: secondsAgo(29) })]
expect(getConnectionMetrics(activities).warnLongestRunningQuery).toBe(false)
})
it('warns for an idle-in-transaction query past the shorter idle threshold (10s)', () => {
const activities = [
activity({ pid: 1, state: 'idle in transaction', transaction_start: secondsAgo(11) }),
]
expect(getConnectionMetrics(activities).warnLongestRunningQuery).toBe(true)
})
})
describe('top blocker', () => {
it('picks the pid blocking the most other queries', () => {
const activities = [
activity({ pid: 1, blocked_by: [] }),
activity({ pid: 2, blocked_by: [1] }),
activity({ pid: 3, blocked_by: [1] }),
activity({ pid: 4, blocked_by: [1] }),
activity({ pid: 5, blocked_by: [2] }),
]
const { queryBlockingTheMostQueries } = getConnectionMetrics(activities)
expect(queryBlockingTheMostQueries?.activity.pid).toBe(1)
expect(queryBlockingTheMostQueries?.count).toBe(4)
})
it('counts transitively - a longer block chain outweighs several short ones', () => {
const activities = [
activity({ pid: 1, blocked_by: [2] }),
activity({ pid: 2, blocked_by: [3] }),
activity({ pid: 3, blocked_by: [] }),
activity({ pid: 4, blocked_by: [5] }),
activity({ pid: 5, blocked_by: [] }),
]
const { queryBlockingTheMostQueries } = getConnectionMetrics(activities)
expect(queryBlockingTheMostQueries?.activity.pid).toBe(3)
expect(queryBlockingTheMostQueries?.count).toBe(2)
})
it('counts a diamond-shaped block pattern once, not per incoming path', () => {
// root blocks both p1 and p2 directly, and both p1 and p2 block w - w must only count once
const activities = [
activity({ pid: 0, blocked_by: [] }),
activity({ pid: 1, blocked_by: [0] }),
activity({ pid: 2, blocked_by: [0] }),
activity({ pid: 3, blocked_by: [1, 2] }),
]
const { queryBlockingTheMostQueries } = getConnectionMetrics(activities)
expect(queryBlockingTheMostQueries?.activity.pid).toBe(0)
expect(queryBlockingTheMostQueries?.count).toBe(3)
})
it('does not warn when the top blocker is under the threshold (3)', () => {
const activities = [
activity({ pid: 1, blocked_by: [] }),
activity({ pid: 2, blocked_by: [1] }),
]
expect(getConnectionMetrics(activities).warnTopBlocker).toBe(false)
})
it('warns once the top blocker meets the threshold (3)', () => {
const activities = [
activity({ pid: 1, blocked_by: [] }),
activity({ pid: 2, blocked_by: [1] }),
activity({ pid: 3, blocked_by: [1] }),
activity({ pid: 4, blocked_by: [1] }),
]
expect(getConnectionMetrics(activities).warnTopBlocker).toBe(true)
})
})
})
describe('getBlockChain', () => {
it('returns just the pid when it is not blocked', () => {
const activities = [activity({ pid: 1, blocked_by: [] })]
expect(getBlockChain(1, activities)).toEqual([1])
})
it('walks blocked_by up to the root, nearest first', () => {
const activities = [
activity({ pid: 1, blocked_by: [2] }),
activity({ pid: 2, blocked_by: [3] }),
activity({ pid: 3, blocked_by: [] }),
]
expect(getBlockChain(1, activities)).toEqual([1, 2, 3])
})
it('only follows the first blocker when blocked by multiple pids', () => {
const activities = [
activity({ pid: 1, blocked_by: [2, 3] }),
activity({ pid: 2, blocked_by: [] }),
activity({ pid: 3, blocked_by: [] }),
]
expect(getBlockChain(1, activities)).toEqual([1, 2])
})
it('stops rather than looping on a cycle', () => {
const activities = [
activity({ pid: 1, blocked_by: [2] }),
activity({ pid: 2, blocked_by: [1] }),
]
expect(getBlockChain(1, activities)).toEqual([1, 2])
})
it('includes a blocker pid even if its own activity record is missing', () => {
const activities = [activity({ pid: 1, blocked_by: [99] })]
expect(getBlockChain(1, activities)).toEqual([1, 99])
})
})
describe('getBlockingChain', () => {
it('returns an empty chain when nothing is blocked by the root', () => {
const activities = [activity({ pid: 1, blocked_by: [] })]
expect(getBlockingChain(1, activities)).toEqual([])
})
it('walks forward from the root, nearest waiter first', () => {
const activities = [
activity({ pid: 1, blocked_by: [2] }),
activity({ pid: 2, blocked_by: [3] }),
activity({ pid: 3, blocked_by: [] }),
]
expect(getBlockingChain(3, activities)).toEqual([2, 1])
})
it('does not require the root pid to have its own activity record', () => {
const activities = [activity({ pid: 2, blocked_by: [1] })]
expect(getBlockingChain(1, activities)).toEqual([2])
})
it('stops rather than looping on a cycle', () => {
const activities = [
activity({ pid: 2, blocked_by: [1] }),
activity({ pid: 3, blocked_by: [2] }),
activity({ pid: 1, blocked_by: [3] }), // would cycle back to the root
]
expect(getBlockingChain(1, activities)).toEqual([2, 3])
})
it('only follows one branch when the root has multiple direct waiters', () => {
const activities = [
activity({ pid: 2, blocked_by: [1] }),
activity({ pid: 3, blocked_by: [1] }),
]
expect(getBlockingChain(1, activities)).toEqual([2])
})
})