Skip to content

Commit f0fbcbd

Browse files
joshenlimawaseem
andauthored
Add preflight EXPLAIN check to table editor rows (supabase#42321)
## Context Part of an investigation to see how we can make the dashboard more resilient for large databases by ensuring that the dashboard never becomes the reason for taking down the database accidentally. Am proposing that for interfaces that rely heavily on queries to the database for data to render, we add preflight checks to ensure that we never run queries that exceed a certain cost threshold (and also have UI handlers to communicate this) - this can be done by running an EXPLAIN query before running the actual query, and if the cost from the EXPLAIN exceeds a specified threshold, the UI throws an error then and skips calling the actual query. ## Demo Am piloting this with the Table Editor, and got an example here in which my table has 500K+ rows, and I'm trying to sort on an unindexed column: https://github.com/user-attachments/assets/ccad2ea9-d62c-4106-8295-2a6df5941474 With this UX, the pros are that - It's relatively seamless and not too invasive, most users won't notice this unless they run into this specific scenario - We can incrementally apply this to other parts of the dashboard, next will probably be Auth Users for example However there are some considerations: - The additional EXPLAIN query adds a bit more latency to the query since its a separate API request to the query endpoint - ^ On a similar note, it will hammer the API a bit more, which may result in higher probability of 429s - However, I reckon that the preflight checks are meant to be used sparingly and only for certain parts of the dashboard that we believe may cause high load. - e.g for the Table Editor, reckon we only need this for fetching rows? The count query is largely optimized already (although we could just add a preflight check there too) - It's just meant to be a safeguard to prevent running heavy queries on the database <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Query preflight with cost checks and a user-facing high-cost dialog showing cost details and remediation suggestions. * Grid exposes an explicit error flag and surfaces richer error metadata. * **Bug Fixes** * Standardized error handling and more consistent error displays across the app. * Explain analysis now reports an additional max-cost metric for queries. * **UI** * Tweaked empty-state interaction/layout and slightly wider header delete control. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Ali Waseem <waseema393@gmail.com>
1 parent ef84ddd commit f0fbcbd

File tree

11 files changed

+302
-63
lines changed

11 files changed

+302
-63
lines changed

apps/studio/components/grid/components/grid/Grid.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import type { PostgresColumn } from '@supabase/postgres-meta'
2-
import { forwardRef, memo, Ref, useMemo, useRef } from 'react'
3-
import DataGrid, { CalculatedColumn, DataGridHandle } from 'react-data-grid'
4-
import { ref as valtioRef } from 'valtio'
5-
6-
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
72
import { handleCopyCell } from 'components/grid/SupabaseGrid.utils'
3+
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
84
import { formatForeignKeys } from 'components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils'
95
import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query'
106
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
@@ -13,23 +9,28 @@ import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
139
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
1410
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
1511
import { useCsvFileDrop } from 'hooks/ui/useCsvFileDrop'
12+
import { Ref, forwardRef, memo, useMemo, useRef } from 'react'
13+
import DataGrid, { CalculatedColumn, DataGridHandle } from 'react-data-grid'
1614
import { useTableEditorStateSnapshot } from 'state/table-editor'
1715
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
1816
import { Button, cn } from 'ui'
1917
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
18+
import { ref as valtioRef } from 'valtio'
19+
2020
import type { GridProps, SupaRow } from '../../types'
2121
import { useOnRowsChange } from './Grid.utils'
2222
import { GridError } from './GridError'
2323
import RowRenderer from './RowRenderer'
2424
import { QueuedOperationType } from '@/state/table-editor-operation-queue.types'
25+
import { ResponseError } from '@/types'
2526

2627
const rowKeyGetter = (row: SupaRow) => {
2728
return row?.idx ?? -1
2829
}
2930

3031
interface IGrid extends GridProps {
3132
rows: SupaRow[]
32-
error: Error | null
33+
error: ResponseError | null
3334
isDisabled?: boolean
3435
isLoading: boolean
3536
isSuccess: boolean
@@ -196,11 +197,10 @@ export const Grid = memo(
196197
{(rows ?? []).length === 0 && (
197198
<div
198199
className={cn(
199-
'absolute top-9 p-2 w-full z-[1] pointer-events-none',
200+
'absolute top-9 p-2 w-full z-[1]',
200201
isTableEmpty && isDraggedOver && 'border-2 border-dashed',
201202
isValidFileDraggedOver ? 'border-brand-600' : 'border-destructive-600'
202203
)}
203-
style={{ height: `calc(100% - 35px)` }}
204204
onDragOver={onDragOver}
205205
onDragLeave={onDragOver}
206206
onDrop={onFileDrop}

apps/studio/components/grid/components/grid/GridError.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,17 @@ import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
99
import { Button } from 'ui'
1010
import { Admonition } from 'ui-patterns'
1111

12-
export const GridError = ({ error }: { error?: any }) => {
12+
import { HighCostError } from '@/components/ui/HighQueryCost'
13+
import { COST_THRESHOLD_ERROR } from '@/data/sql/execute-sql-query'
14+
import { ResponseError } from '@/types'
15+
16+
export const GridError = ({ error }: { error?: ResponseError | null }) => {
1317
const { filters } = useTableFilter()
1418
const { sorts } = useTableSort()
1519
const snap = useTableEditorTableStateSnapshot()
1620

21+
if (!error) return null
22+
1723
const tableEntityType = snap.originalTable?.entity_type
1824
const isForeignTable = tableEntityType === ENTITY_TYPE.FOREIGN_TABLE
1925

@@ -26,7 +32,19 @@ export const GridError = ({ error }: { error?: any }) => {
2632
const isInvalidOrderingOperatorError =
2733
sorts.length > 0 && error?.message?.includes('identify an ordering operator')
2834

29-
if (isForeignTableMissingVaultKeyError) {
35+
const isHighCostError = error?.message.includes(COST_THRESHOLD_ERROR)
36+
37+
if (isHighCostError) {
38+
return (
39+
<HighCostError
40+
error={error}
41+
suggestions={[
42+
'Remove any sorts or filters on unindexed columns, or',
43+
'Create indexes for columns that you want to filter or sort on',
44+
]}
45+
/>
46+
)
47+
} else if (isForeignTableMissingVaultKeyError) {
3048
return <ForeignTableMissingVaultKeyError />
3149
} else if (isInvalidSyntaxError) {
3250
return <InvalidSyntaxError error={error} />
@@ -67,7 +85,7 @@ const ForeignTableMissingVaultKeyError = () => {
6785
)
6886
}
6987

70-
const InvalidSyntaxError = ({ error }: { error?: any }) => {
88+
const InvalidSyntaxError = ({ error }: { error: ResponseError }) => {
7189
const { onApplyFilters } = useTableFilter()
7290

7391
return (
@@ -94,9 +112,9 @@ const InvalidSyntaxError = ({ error }: { error?: any }) => {
94112
)
95113
}
96114

97-
const InvalidOrderingOperatorError = ({ error }: { error: any }) => {
115+
const InvalidOrderingOperatorError = ({ error }: { error: ResponseError }) => {
98116
const { sorts, onApplySorts } = useTableSort()
99-
const invalidDataType = (error?.message ?? '').split('type ').pop()
117+
const invalidDataType = (error.message ?? '').split('type ').pop() ?? ''
100118
const formattedInvalidDataType = invalidDataType.includes('json')
101119
? invalidDataType.toUpperCase()
102120
: invalidDataType
@@ -127,13 +145,13 @@ const InvalidOrderingOperatorError = ({ error }: { error: any }) => {
127145
)
128146
}
129147

130-
const GeneralError = ({ error }: { error: any }) => {
148+
const GeneralError = ({ error }: { error: ResponseError }) => {
131149
const { filters } = useTableFilter()
132150

133151
return (
134152
<AlertError
135-
className="pointer-events-auto"
136153
error={error}
154+
className="pointer-events-auto"
137155
subject="Failed to retrieve rows from table"
138156
>
139157
{filters.length > 0 && (

apps/studio/components/grid/components/header/sort/SortRow.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1+
import type { DragItem, Sort } from 'components/grid/types'
12
import type { XYCoord } from 'dnd-core'
23
import { Menu, X } from 'lucide-react'
34
import { memo, useRef } from 'react'
45
import { useDrag, useDrop } from 'react-dnd'
5-
6-
import type { DragItem, Sort } from 'components/grid/types'
76
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
87
import { Button, Switch } from 'ui'
98

@@ -127,6 +126,7 @@ const SortRow = ({ index, columnName, sort, onDelete, onToggle, onDrag }: SortRo
127126
icon={<X strokeWidth={1.5} />}
128127
size="tiny"
129128
type="text"
129+
className="w-7"
130130
onClick={() => onDelete(columnName)}
131131
/>
132132
</div>

apps/studio/components/interfaces/ExplainVisualizer/ExplainVisualizer.parser.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ExplainNode, QueryPlanRow } from './ExplainVisualizer.types'
33
export interface ExplainSummary {
44
totalTime: number
55
totalCost: number
6+
maxCost: number
67
hasSeqScan: boolean
78
seqScanTables: string[]
89
hasIndexScan: boolean
@@ -246,6 +247,7 @@ export function calculateSummary(tree: ExplainNode[]): ExplainSummary {
246247
const stats: ExplainSummary = {
247248
totalTime: 0,
248249
totalCost: 0,
250+
maxCost: 0,
249251
hasSeqScan: false,
250252
seqScanTables: [],
251253
hasIndexScan: false,
@@ -256,7 +258,7 @@ export function calculateSummary(tree: ExplainNode[]): ExplainSummary {
256258
stats.totalTime = Math.max(stats.totalTime, node.actualTime.end)
257259
}
258260
if (node.cost) {
259-
stats.totalCost = Math.max(stats.totalCost, node.cost.end)
261+
stats.maxCost = Math.max(stats.maxCost, node.cost.end)
260262
}
261263
const op = node.operation.toLowerCase()
262264
if (op.includes('seq scan')) {
@@ -270,6 +272,8 @@ export function calculateSummary(tree: ExplainNode[]): ExplainSummary {
270272
node.children.forEach(traverse)
271273
}
272274
tree.forEach(traverse)
275+
276+
stats.totalCost = tree[0]?.cost?.end ?? 0
273277
return stats
274278
}
275279

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import {
2+
Button,
3+
Dialog,
4+
DialogClose,
5+
DialogContent,
6+
DialogDescription,
7+
DialogFooter,
8+
DialogHeader,
9+
DialogSection,
10+
DialogSectionSeparator,
11+
DialogTitle,
12+
DialogTrigger,
13+
Tooltip,
14+
TooltipContent,
15+
TooltipTrigger,
16+
} from 'ui'
17+
import { Admonition } from 'ui-patterns'
18+
19+
import { DocsButton } from './DocsButton'
20+
import { InlineLinkClassName } from './InlineLink'
21+
import { DOCS_URL } from '@/lib/constants'
22+
import { ResponseError } from '@/types'
23+
24+
interface HighQueryCostErrorProps {
25+
error: ResponseError
26+
suggestions?: string[]
27+
}
28+
29+
export const HighCostError = ({ error, suggestions }: HighQueryCostErrorProps) => {
30+
// [Joshen] The CTA could be to use a read replica to query or something?
31+
return (
32+
<Admonition
33+
type="default"
34+
title="Data not loaded to protect database performance"
35+
description="The query to retrieve the data was not run as it could place heavy load on the database and impact performance"
36+
>
37+
<HighQueryCostDialog error={error} suggestions={suggestions} />
38+
</Admonition>
39+
)
40+
}
41+
42+
const HighQueryCostDialog = ({ error, suggestions = [] }: HighQueryCostErrorProps) => {
43+
const metadata = error.metadata
44+
45+
return (
46+
<Dialog>
47+
<DialogTrigger asChild>
48+
<Button type="default" className="mt-2">
49+
Learn more
50+
</Button>
51+
</DialogTrigger>
52+
<DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
53+
<DialogHeader>
54+
<DialogTitle>Estimated query cost exceeds safety thresholds</DialogTitle>
55+
<DialogDescription>
56+
Preventive measure to mitigate impacting the database
57+
</DialogDescription>
58+
</DialogHeader>
59+
<DialogSectionSeparator />
60+
<DialogSection className="flex flex-col gap-y-2 text-sm">
61+
<p>
62+
The dashboard runs optimized SQL queries on your project’s database to load data for
63+
this interface.
64+
</p>
65+
<p>
66+
However, the query was skipped as its{' '}
67+
<Tooltip>
68+
<TooltipTrigger className={InlineLinkClassName}>estimated cost</TooltipTrigger>
69+
<TooltipContent side="bottom" className="flex flex-col gap-y-1">
70+
<p>Estimated cost: {metadata?.cost.toLocaleString()}</p>
71+
<p className="text-foreground-light">
72+
Determined via the <code className="text-code-inline">EXPLAIN</code> command
73+
</p>
74+
</TooltipContent>
75+
</Tooltip>{' '}
76+
is high and could place significant load on the database.
77+
</p>
78+
{suggestions.length > 0 && (
79+
<div className="flex flex-col gap-y-1">
80+
<p>You may check the following to ensure that the query cost is lower</p>
81+
<ul className="list-disc pl-6">
82+
{suggestions.map((x) => (
83+
<li key={x}>{x}</li>
84+
))}
85+
</ul>
86+
</div>
87+
)}
88+
</DialogSection>
89+
<DialogFooter>
90+
<DocsButton
91+
href={`${DOCS_URL}/guides/troubleshooting/understanding-postgresql-explain-output-Un9dqX`}
92+
/>
93+
<DialogClose asChild>
94+
<Button type="default" className="opacity-100">
95+
Understood
96+
</Button>
97+
</DialogClose>
98+
</DialogFooter>
99+
</DialogContent>
100+
</Dialog>
101+
)
102+
}

apps/studio/data/fetchers.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import * as Sentry from '@sentry/nextjs'
2-
3-
import createClient from 'openapi-fetch'
4-
52
import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants'
63
import { IS_PLATFORM, getAccessToken } from 'common'
74
import { API_URL } from 'lib/constants'
85
import { uuidv4 } from 'lib/helpers'
6+
import createClient from 'openapi-fetch'
97
import { ResponseError } from 'types'
10-
import type { paths } from './api' // generated from openapi-typescript
8+
9+
import type { paths } from './api'
10+
import { ErrorMetadata } from '@/types/base'
11+
12+
// generated from openapi-typescript
1113

1214
const DEFAULT_HEADERS = { Accept: 'application/json' }
1315

@@ -164,9 +166,20 @@ export const handleError = (error: unknown, options: HandleErrorOptions = {}): n
164166
'requestPathname' in error && typeof error.requestPathname === 'string'
165167
? error.requestPathname
166168
: undefined
169+
const metadata =
170+
'metadata' in error && typeof error.metadata === 'object' && !!error.metadata
171+
? (error.metadata as ErrorMetadata)
172+
: undefined
167173

168174
if (errorMessage) {
169-
throw new ResponseError(errorMessage, errorCode, requestId, retryAfter, requestPathname)
175+
throw new ResponseError(
176+
errorMessage,
177+
errorCode,
178+
requestId,
179+
retryAfter,
180+
requestPathname,
181+
metadata
182+
)
170183
}
171184
}
172185

apps/studio/data/sql/execute-sql-mutation.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { useMutation, useQueryClient } from '@tanstack/react-query'
2-
import { toast } from 'sonner'
3-
42
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
53
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
64
import { sqlEventParser } from 'lib/sql-event-parser'
5+
import { toast } from 'sonner'
76
import { UseCustomMutationOptions } from 'types'
8-
import { executeSql, ExecuteSqlData, ExecuteSqlVariables } from './execute-sql-query'
7+
8+
import { ExecuteSqlData, ExecuteSqlVariables, executeSql } from './execute-sql-query'
99

1010
// [Joshen] Intention is that we invalidate all database related keys whenever running a mutation related query
1111
// So we attempt to ignore all the non-related query keys. We could probably look into grouping our query keys better
@@ -25,19 +25,24 @@ export type QueryResponseError = {
2525
severity: string
2626
}
2727

28+
type ExecuteSqlMutationVariables = ExecuteSqlVariables & {
29+
autoLimit?: number
30+
contextualInvalidation?: boolean
31+
}
32+
2833
export const useExecuteSqlMutation = ({
2934
onSuccess,
3035
onError,
3136
...options
3237
}: Omit<
33-
UseCustomMutationOptions<ExecuteSqlData, QueryResponseError, ExecuteSqlVariables>,
38+
UseCustomMutationOptions<ExecuteSqlData, QueryResponseError, ExecuteSqlMutationVariables>,
3439
'mutationFn'
3540
> = {}) => {
3641
const queryClient = useQueryClient()
3742
const { mutate: sendEvent } = useSendEventMutation()
3843
const { data: org } = useSelectedOrganizationQuery()
3944

40-
return useMutation<ExecuteSqlData, QueryResponseError, ExecuteSqlVariables>({
45+
return useMutation<ExecuteSqlData, QueryResponseError, ExecuteSqlMutationVariables>({
4146
mutationFn: (args) => executeSql(args),
4247
async onSuccess(data, variables, context) {
4348
const { contextualInvalidation, sql, projectRef } = variables

0 commit comments

Comments
 (0)