mirror of
https://github.com/supabase/supabase.git
synced 2026-09-09 19:42:46 +08:00
## 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 -->
334 lines
11 KiB
TypeScript
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])
|
|
})
|
|
})
|