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