mirror of
https://github.com/supabase/supabase.git
synced 2026-05-22 17:00:43 +08:00
## 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>
223 lines
6.8 KiB
TypeScript
223 lines
6.8 KiB
TypeScript
import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants'
|
|
import { QueryKey, useQuery } from '@tanstack/react-query'
|
|
import { handleError as handleErrorFetchers, post } from 'data/fetchers'
|
|
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
|
import { MB, PROJECT_STATUS } from 'lib/constants'
|
|
import {
|
|
ROLE_IMPERSONATION_NO_RESULTS,
|
|
ROLE_IMPERSONATION_SQL_LINE_COUNT,
|
|
} from 'lib/role-impersonation'
|
|
import type { ResponseError, UseCustomQueryOptions } from 'types'
|
|
|
|
import { sqlKeys } from './keys'
|
|
import {
|
|
calculateSummary,
|
|
createNodeTree,
|
|
} from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.parser'
|
|
|
|
/**
|
|
* [Joshen] Done a bit of stress testing and experimentation, tho we should still observe and tweak where necessary
|
|
* From what I understand a query cost of 100,000 is considered to be "heavy", and 1M is "potentially dangerous"
|
|
* Reckon we ensure that the dashboard just caps query costs at "heavy", so that it doesn't impact the DB for other queries
|
|
* (e.g from the user's application)
|
|
*/
|
|
const COST_THRESHOLD = 100_000
|
|
export const COST_THRESHOLD_ERROR = 'Query cost exceeds threshold'
|
|
|
|
export type ExecuteSqlVariables = {
|
|
projectRef?: string
|
|
connectionString?: string | null
|
|
sql: string
|
|
queryKey?: QueryKey
|
|
handleError?: (error: ResponseError) => { result: any }
|
|
isRoleImpersonationEnabled?: boolean
|
|
/**
|
|
* Disables transaction mode - should be used only for manual queries ran via the SQL Editor
|
|
* */
|
|
isStatementTimeoutDisabled?: boolean
|
|
/**
|
|
* Runs an EXPLAIN before actually running the query, rejects the query if cost exceeds a threshold.
|
|
* Intended to be used for interfaces that heavily rely on queries on the DB
|
|
* */
|
|
preflightCheck?: boolean
|
|
}
|
|
|
|
/**
|
|
* Executes a SQL query against the user's instance.
|
|
*
|
|
* @throws {Error}
|
|
*/
|
|
export async function executeSql<T = any>(
|
|
{
|
|
projectRef,
|
|
connectionString,
|
|
sql,
|
|
queryKey,
|
|
handleError,
|
|
isRoleImpersonationEnabled = false,
|
|
isStatementTimeoutDisabled = false,
|
|
preflightCheck = false,
|
|
}: ExecuteSqlVariables,
|
|
signal?: AbortSignal,
|
|
headersInit?: HeadersInit,
|
|
fetcherOverride?: (options: {
|
|
query: string
|
|
headers?: HeadersInit
|
|
}) => Promise<{ data: T } | { error: ResponseError }>
|
|
): Promise<{ result: T }> {
|
|
if (!projectRef) throw new Error('projectRef is required')
|
|
|
|
const sqlSize = new Blob([sql]).size
|
|
// [Joshen] I think the limit is around 1MB from testing, but its not exactly 1MB it seems
|
|
if (sqlSize > 0.98 * MB) {
|
|
throw new Error('Query is too large to be run via the SQL Editor')
|
|
}
|
|
|
|
let headers = new Headers(headersInit)
|
|
if (connectionString) headers.set('x-connection-encrypted', connectionString)
|
|
|
|
let data
|
|
let error
|
|
|
|
if (fetcherOverride) {
|
|
const result = await fetcherOverride({ query: sql, headers })
|
|
if ('data' in result) {
|
|
data = result.data
|
|
} else {
|
|
error = result.error
|
|
}
|
|
} else {
|
|
const options = {
|
|
signal,
|
|
headers,
|
|
params: {
|
|
path: { ref: projectRef },
|
|
header: {
|
|
'x-connection-encrypted': connectionString ?? '',
|
|
'x-pg-application-name': isStatementTimeoutDisabled
|
|
? 'supabase/dashboard-query-editor'
|
|
: DEFAULT_PLATFORM_APPLICATION_NAME,
|
|
},
|
|
},
|
|
}
|
|
|
|
if (preflightCheck) {
|
|
/**
|
|
* [Joshen] Note that I've intentionally omitted error handling here as I'm opting
|
|
* to NOT block the UI if the preflight check fails for any reason.
|
|
*/
|
|
|
|
const { data: costCheck } = await post('/platform/pg-meta/{ref}/query', {
|
|
...options,
|
|
body: {
|
|
query: `explain ${sql}`,
|
|
disable_statement_timeout: isStatementTimeoutDisabled,
|
|
},
|
|
})
|
|
const parsedTree = !!costCheck ? createNodeTree(costCheck) : undefined
|
|
const summary = !!parsedTree ? calculateSummary(parsedTree) : undefined
|
|
const cost = summary?.totalCost ?? 0
|
|
|
|
if (cost >= COST_THRESHOLD) {
|
|
return handleErrorFetchers({
|
|
message: COST_THRESHOLD_ERROR,
|
|
code: cost,
|
|
metadata: { cost, sql },
|
|
})
|
|
}
|
|
}
|
|
|
|
const key =
|
|
queryKey?.filter((seg) => typeof seg === 'string' || typeof seg === 'number').join('-') ?? ''
|
|
const result = await post('/platform/pg-meta/{ref}/query', {
|
|
...options,
|
|
body: { query: sql, disable_statement_timeout: isStatementTimeoutDisabled },
|
|
params: {
|
|
...options.params,
|
|
// @ts-expect-error: This is just a client side thing to identify queries better
|
|
query: { key },
|
|
},
|
|
})
|
|
|
|
data = result.data
|
|
error = result.error
|
|
}
|
|
|
|
if (error) {
|
|
if (
|
|
isRoleImpersonationEnabled &&
|
|
typeof error === 'object' &&
|
|
error !== null &&
|
|
'error' in error &&
|
|
'formattedError' in error
|
|
) {
|
|
let updatedError = error as { error: string; formattedError: string }
|
|
|
|
const regex = /LINE (\d+):/im
|
|
const [, lineNumberStr] = regex.exec(updatedError.error) ?? []
|
|
const lineNumber = Number(lineNumberStr)
|
|
if (!isNaN(lineNumber)) {
|
|
updatedError = {
|
|
...updatedError,
|
|
error: updatedError.error.replace(
|
|
regex,
|
|
`LINE ${lineNumber - ROLE_IMPERSONATION_SQL_LINE_COUNT}:`
|
|
),
|
|
formattedError: updatedError.formattedError.replace(
|
|
regex,
|
|
`LINE ${lineNumber - ROLE_IMPERSONATION_SQL_LINE_COUNT}:`
|
|
),
|
|
}
|
|
}
|
|
|
|
error = updatedError as any
|
|
}
|
|
|
|
if (handleError !== undefined) return handleError(error as any)
|
|
else handleErrorFetchers(error)
|
|
}
|
|
|
|
if (
|
|
isRoleImpersonationEnabled &&
|
|
Array.isArray(data) &&
|
|
data?.[0]?.[ROLE_IMPERSONATION_NO_RESULTS] === 1
|
|
) {
|
|
return { result: [] as T }
|
|
}
|
|
|
|
return { result: data as T }
|
|
}
|
|
|
|
export type ExecuteSqlData = Awaited<ReturnType<typeof executeSql<any[]>>>
|
|
export type ExecuteSqlError = ResponseError
|
|
|
|
/**
|
|
* @deprecated Use the regular useQuery with a function that calls executeSql() instead
|
|
*/
|
|
export const useExecuteSqlQuery = <TData = ExecuteSqlData>(
|
|
{
|
|
projectRef,
|
|
connectionString,
|
|
sql,
|
|
queryKey,
|
|
handleError,
|
|
isRoleImpersonationEnabled,
|
|
}: ExecuteSqlVariables,
|
|
{ enabled = true, ...options }: UseCustomQueryOptions<ExecuteSqlData, ExecuteSqlError, TData> = {}
|
|
) => {
|
|
const { data: project } = useSelectedProjectQuery()
|
|
const isActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
|
|
|
|
return useQuery<ExecuteSqlData, ExecuteSqlError, TData>({
|
|
queryKey: sqlKeys.query(projectRef, queryKey ?? [btoa(sql)]),
|
|
queryFn: ({ signal }) =>
|
|
executeSql(
|
|
{ projectRef, connectionString, sql, queryKey, handleError, isRoleImpersonationEnabled },
|
|
signal
|
|
),
|
|
enabled: enabled && typeof projectRef !== 'undefined' && isActive,
|
|
staleTime: 0,
|
|
...options,
|
|
})
|
|
}
|