ConnectionPooling.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { PermissionAction } from '@supabase/shared-types/out/constants'
  3. import { useParams } from 'common'
  4. import { capitalize } from 'lodash'
  5. import Link from 'next/link'
  6. import { Fragment, useEffect } from 'react'
  7. import { SubmitHandler, useForm } from 'react-hook-form'
  8. import { toast } from 'sonner'
  9. import {
  10. Alert,
  11. AlertDescription,
  12. AlertTitle,
  13. Badge,
  14. Button,
  15. Form,
  16. FormControl,
  17. FormField,
  18. FormInputGroupInput,
  19. InputGroup,
  20. InputGroupAddon,
  21. InputGroupText,
  22. Separator,
  23. } from 'ui'
  24. import { Admonition } from 'ui-patterns'
  25. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  26. import {
  27. PageSection,
  28. PageSectionAside,
  29. PageSectionContent,
  30. PageSectionMeta,
  31. PageSectionSummary,
  32. PageSectionTitle,
  33. } from 'ui-patterns/PageSection'
  34. import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  35. import z from 'zod'
  36. import { POOLING_OPTIMIZATIONS } from './ConnectionPooling.constants'
  37. import AlertError from '@/components/ui/AlertError'
  38. import { DocsButton } from '@/components/ui/DocsButton'
  39. import { FormActions } from '@/components/ui/Forms/FormActions'
  40. import { InlineLink } from '@/components/ui/InlineLink'
  41. import Panel from '@/components/ui/Panel'
  42. import { useMaxConnectionsQuery } from '@/data/database/max-connections-query'
  43. import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query'
  44. import { usePgbouncerConfigurationUpdateMutation } from '@/data/database/pgbouncer-config-update-mutation'
  45. import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
  46. import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
  47. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  48. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  49. import { DOCS_URL } from '@/lib/constants'
  50. const formId = 'pooling-configuration-form'
  51. const PoolingConfigurationFormSchema = z.object({
  52. default_pool_size: z.preprocess(
  53. (val) => (val === '' || val === null || val === undefined ? undefined : val),
  54. z.coerce.number().optional()
  55. ),
  56. max_client_conn: z.preprocess(
  57. (val) => (val === '' || val === null || val === undefined ? undefined : val),
  58. z.coerce.number().optional()
  59. ),
  60. })
  61. /**
  62. * [Joshen] PgBouncer configuration will be the main endpoint for GET and PATCH of pooling config
  63. */
  64. export const ConnectionPooling = () => {
  65. const { ref: projectRef } = useParams()
  66. const { data: project } = useSelectedProjectQuery()
  67. const { can: canUpdateConnectionPoolingConfiguration } = useAsyncCheckPermissions(
  68. PermissionAction.UPDATE,
  69. 'projects',
  70. { resource: { project_id: project?.id } }
  71. )
  72. const {
  73. data: pgbouncerConfig,
  74. error: pgbouncerConfigError,
  75. isPending: isLoadingPgbouncerConfig,
  76. isError: isErrorPgbouncerConfig,
  77. isSuccess: isSuccessPgbouncerConfig,
  78. } = usePgbouncerConfigQuery({ projectRef })
  79. const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler')
  80. const disablePoolModeSelection = !hasDedicatedPooler
  81. const { data: maxConnData } = useMaxConnectionsQuery({
  82. projectRef: project?.ref,
  83. connectionString: project?.connectionString,
  84. })
  85. const { data: addons, isSuccess: isSuccessAddons } = useProjectAddonsQuery({ projectRef })
  86. const { mutate: updatePoolerConfig, isPending: isUpdatingPoolerConfig } =
  87. usePgbouncerConfigurationUpdateMutation()
  88. const hasIpv4Addon = !!addons?.selected_addons.find((addon) => addon.type === 'ipv4')
  89. const computeInstance = addons?.selected_addons.find((addon) => addon.type === 'compute_instance')
  90. const computeSize =
  91. computeInstance?.variant.name ?? capitalize(project?.infra_compute_size) ?? 'Nano'
  92. const poolingOptimizations =
  93. POOLING_OPTIMIZATIONS[
  94. (computeInstance?.variant.identifier as keyof typeof POOLING_OPTIMIZATIONS) ??
  95. (project?.infra_compute_size === 'nano' ? 'ci_nano' : 'ci_micro')
  96. ]
  97. const defaultPoolSize = poolingOptimizations.poolSize ?? 15
  98. const defaultMaxClientConn = poolingOptimizations.maxClientConn ?? 200
  99. const form = useForm<z.infer<typeof PoolingConfigurationFormSchema>>({
  100. resolver: zodResolver(PoolingConfigurationFormSchema as any),
  101. defaultValues: {
  102. default_pool_size: undefined,
  103. max_client_conn: undefined,
  104. },
  105. })
  106. const { default_pool_size } = form.watch()
  107. const connectionPoolingUnavailable = pgbouncerConfig?.pool_mode === null
  108. const ignoreStartupParameters = pgbouncerConfig?.ignore_startup_parameters
  109. const onSubmit: SubmitHandler<z.infer<typeof PoolingConfigurationFormSchema>> = async (data) => {
  110. const { default_pool_size } = data
  111. if (!projectRef) return console.error('Project ref is required')
  112. updatePoolerConfig(
  113. {
  114. ref: projectRef,
  115. default_pool_size: default_pool_size === null ? undefined : default_pool_size,
  116. ignore_startup_parameters: ignoreStartupParameters ?? '',
  117. },
  118. {
  119. onSuccess: (data) => {
  120. toast.success(`Successfully updated pooler configuration`)
  121. if (data) {
  122. form.reset({
  123. default_pool_size: data.default_pool_size,
  124. })
  125. }
  126. },
  127. }
  128. )
  129. }
  130. const resetForm = () => {
  131. form.reset({
  132. default_pool_size: pgbouncerConfig?.default_pool_size ?? defaultPoolSize,
  133. max_client_conn: pgbouncerConfig?.max_client_conn ?? defaultMaxClientConn,
  134. })
  135. }
  136. useEffect(() => {
  137. if (isSuccessPgbouncerConfig) resetForm()
  138. }, [isSuccessPgbouncerConfig])
  139. return (
  140. <PageSection id="connection-pooler">
  141. <PageSectionMeta>
  142. <PageSectionSummary>
  143. <PageSectionTitle>Connection pooling</PageSectionTitle>
  144. </PageSectionSummary>
  145. <PageSectionAside>
  146. <DocsButton
  147. href={`${DOCS_URL}/guides/database/connecting-to-postgres#connection-pooler`}
  148. />
  149. </PageSectionAside>
  150. </PageSectionMeta>
  151. <PageSectionContent className="space-y-4">
  152. {isSuccessAddons && !disablePoolModeSelection && !hasIpv4Addon && (
  153. <Admonition
  154. type="default"
  155. layout="responsive"
  156. title="Dedicated pooler uses IPv6 by default"
  157. description="Connections from IPv4-only networks require enabling the IPv4 add-on on your project instance."
  158. actions={
  159. <Button type="default" asChild>
  160. <Link href={`/project/${projectRef}/settings/addons?panel=ipv4`}>
  161. Enable IPv4 add-on
  162. </Link>
  163. </Button>
  164. }
  165. />
  166. )}
  167. <Panel
  168. noMargin
  169. footer={
  170. <FormActions
  171. form={formId}
  172. isSubmitting={isUpdatingPoolerConfig}
  173. hasChanges={form.formState.isDirty}
  174. handleReset={() => resetForm()}
  175. helper={
  176. !canUpdateConnectionPoolingConfiguration
  177. ? 'You need additional permissions to update connection pooling settings'
  178. : undefined
  179. }
  180. />
  181. }
  182. >
  183. <Panel.Content>
  184. {isLoadingPgbouncerConfig && (
  185. <div className="flex flex-col gap-y-4">
  186. {Array.from({ length: 4 }).map((_, i) => (
  187. <Fragment key={`loader-${i}`}>
  188. <div className="grid gap-2 items-center md:grid md:grid-cols-12 md:gap-x-4 w-full">
  189. <ShimmeringLoader className="h-4 w-1/3 col-span-4" delayIndex={i} />
  190. <ShimmeringLoader className="h-8 w-full col-span-8" delayIndex={i} />
  191. </div>
  192. <Separator />
  193. </Fragment>
  194. ))}
  195. <ShimmeringLoader className="h-8 w-full" />
  196. </div>
  197. )}
  198. {isErrorPgbouncerConfig && (
  199. <AlertError
  200. error={pgbouncerConfigError}
  201. subject="Failed to retrieve connection pooler configuration"
  202. />
  203. )}
  204. {connectionPoolingUnavailable && (
  205. <Admonition
  206. type="default"
  207. title="Unable to retrieve pooling configuration"
  208. description="Please start a new project to enable this feature"
  209. />
  210. )}
  211. {isSuccessPgbouncerConfig && !connectionPoolingUnavailable && (
  212. <>
  213. <div className="flex flex-row gap-2 justify-between w-full">
  214. <div className="flex flex-col text-sm">
  215. <h5 className="text-foreground font-normal">Connection poolers</h5>
  216. <p className="text-foreground-lighter">
  217. Configuration is shared across all connection poolers.
  218. </p>
  219. </div>
  220. <div className="flex flex-row gap-1 items-center">
  221. <Badge>Shared</Badge>
  222. {!disablePoolModeSelection && <Badge>Dedicated</Badge>}
  223. </div>
  224. </div>
  225. <Separator className="bg-border -mx-6 w-[calc(100%+3rem)] my-4" />
  226. <Form {...form}>
  227. <form
  228. id={formId}
  229. className="flex flex-col gap-y-4 w-full"
  230. onSubmit={form.handleSubmit(onSubmit)}
  231. >
  232. <FormField
  233. control={form.control}
  234. name="default_pool_size"
  235. render={({ field }) => (
  236. <FormItemLayout
  237. layout="flex-row-reverse"
  238. label="Connection pool size"
  239. description={
  240. <p>
  241. The maximum number of connections made to the underlying Postgres
  242. cluster, per user+db combination. Pool size has a default of{' '}
  243. {defaultPoolSize} based on your compute size of {computeSize}.
  244. </p>
  245. }
  246. className="[&>div]:md:w-1/2 [&>div]:xl:w-2/5 [&>div>div]:w-full"
  247. >
  248. <FormControl>
  249. <InputGroup>
  250. <FormInputGroupInput
  251. {...field}
  252. type="number"
  253. className="w-full"
  254. value={field.value ?? ''}
  255. placeholder={defaultPoolSize.toString()}
  256. onChange={(event) =>
  257. field.onChange(
  258. isNaN(event.target.valueAsNumber)
  259. ? null
  260. : event.target.valueAsNumber
  261. )
  262. }
  263. />
  264. <InputGroupAddon align="inline-end">
  265. <InputGroupText>connections</InputGroupText>
  266. </InputGroupAddon>
  267. </InputGroup>
  268. </FormControl>
  269. {!!maxConnData &&
  270. (default_pool_size ?? 15) > maxConnData.maxConnections * 0.8 && (
  271. <Alert variant="warning" className="mt-2">
  272. <AlertTitle className="text-foreground">
  273. Pool size is greater than 80% of the max connections (
  274. {maxConnData.maxConnections}) on your database
  275. </AlertTitle>
  276. <AlertDescription>
  277. This may result in instability and unreliability with your
  278. database connections.
  279. </AlertDescription>
  280. </Alert>
  281. )}
  282. </FormItemLayout>
  283. )}
  284. />
  285. <Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
  286. <FormField
  287. control={form.control}
  288. disabled
  289. name="max_client_conn"
  290. render={({ field }) => (
  291. <FormItemLayout
  292. layout="flex-row-reverse"
  293. label="Max client connections"
  294. className="[&>div]:md:w-1/2 [&>div]:xl:w-2/5 [&>div>div]:w-full"
  295. description={
  296. <>
  297. <p>
  298. The maximum number of concurrent client connections allowed. This
  299. value is fixed at {defaultMaxClientConn} based on your compute size
  300. of {computeSize} and cannot be changed.{' '}
  301. <InlineLink
  302. href={`${DOCS_URL}/guides/database/connection-management#configuring-supavisors-pool-size`}
  303. >
  304. Learn more
  305. </InlineLink>
  306. </p>
  307. </>
  308. }
  309. >
  310. <FormControl>
  311. <InputGroup>
  312. <FormInputGroupInput
  313. {...field}
  314. type="number"
  315. className="w-full"
  316. value={pgbouncerConfig?.max_client_conn ?? ''}
  317. placeholder={defaultMaxClientConn.toString()}
  318. onChange={(event) =>
  319. field.onChange(
  320. isNaN(event.target.valueAsNumber)
  321. ? null
  322. : event.target.valueAsNumber
  323. )
  324. }
  325. />
  326. <InputGroupAddon align="inline-end">
  327. <InputGroupText>clients</InputGroupText>
  328. </InputGroupAddon>
  329. </InputGroup>
  330. </FormControl>
  331. </FormItemLayout>
  332. )}
  333. />
  334. </form>
  335. </Form>
  336. </>
  337. )}
  338. </Panel.Content>
  339. </Panel>
  340. </PageSectionContent>
  341. </PageSection>
  342. )
  343. }