UNPKG

@clickup/ent-framework

Version:

A PostgreSQL graph-database-alike library with microsharding and row-level security

219 lines (197 loc) 7.04 kB
import defaults from "lodash/defaults"; import range from "lodash/range"; import type { PoolConfig } from "pg"; import { Pool } from "pg"; import type { ClientQueryLoggerProps, SwallowedErrorLoggerProps, } from "../abstract/Loggers"; import type { MaybeCallable, PickPartial } from "../internal/misc"; import { jitter, maybeCall, runInVoid } from "../internal/misc"; import { Ref } from "../internal/Ref"; import type { PgClientConn, PgClientOptions } from "./PgClient"; import { PgClient } from "./PgClient"; /** * Options for PgClientPool constructor. */ export interface PgClientPoolOptions extends PgClientOptions { /** Node-Postgres config. We can't make it MaybeCallable unfortunately, * because it's used to initialize Node-Postgres Pool. */ config: PoolConfig & { min?: number | undefined }; /** Pool class (constructor) compatible with node-postgres Pool. */ Pool?: typeof Pool; /** Close the connection after the query if it was opened long time ago. */ maxConnLifetimeMs?: MaybeCallable<number>; /** Jitter for maxConnLifetimeMs. */ maxConnLifetimeJitter?: MaybeCallable<number>; /** Add not more than this number of connections in each prewarm interval. New * connections are expensive to establish (especially when SSL is enabled). */ prewarmIntervalStep?: MaybeCallable<number>; /** How often to send bursts of prewarm queries to all Clients to keep the * minimal number of open connections. */ prewarmIntervalMs?: MaybeCallable<number>; /** Jitter for prewarmIntervalMs. */ prewarmIntervalJitter?: MaybeCallable<number>; /** What prewarm query to send. */ prewarmQuery?: MaybeCallable<string>; } /** * This class carries connection pooling logic only and delegates the rest to * PgClient base class. * * The idea is that in each particular project, people may have they own classes * derived from PgClient, in case the codebase already has some existing * connection pooling solution. They don't have to use PgClientPool. */ export class PgClientPool extends PgClient { /** Default values for the constructor options. */ static override readonly DEFAULT_OPTIONS: Required< PickPartial<PgClientPoolOptions> > = { ...super.DEFAULT_OPTIONS, Pool, maxConnLifetimeMs: 0, maxConnLifetimeJitter: 0.5, prewarmIntervalStep: 1, /** The default value is half of the default node-postgres'es * idleTimeoutMillis=10s. Together with 1..1.5x jitter * (prewarmIntervalJitter=0.5), it is still slightly below * idleTimeoutMillis, and thus, doesn't let Ent Framework close the * connections prematurely. */ prewarmIntervalMs: 5000, prewarmIntervalJitter: 0.5, prewarmQuery: 'SELECT 1 AS "prewarmQuery"', }; /** PG connection pool to use. */ private readonly pool: Pool; /** Prewarming periodic timer (if scheduled). */ private readonly prewarmTimeout = new Ref<NodeJS.Timeout | null>(null); /** Whether the pool has been ended and is not usable anymore. */ private readonly ended = new Ref(false); /** PgClientPool configuration options. */ override readonly options: Required<PgClientPoolOptions>; constructor(options: PgClientPoolOptions) { super(options); this.options = defaults( {}, options, (this as PgClient).options, PgClientPool.DEFAULT_OPTIONS, ); this.pool = new this.options.Pool({ allowExitOnIdle: true, ...this.options.config, }) .on("connect", (client: PgClientConn) => { // Called only once, after the connection is 1st created. const maxConnLifetimeMs = maybeCall(this.options.maxConnLifetimeMs); if (maxConnLifetimeMs > 0) { client.closeAt = Date.now() + Math.round( maxConnLifetimeMs * jitter(maybeCall(this.options.maxConnLifetimeJitter)), ); } // Sets a "default error" handler to not let errors leak to e.g. Jest // and the outside world as "unhandled error". Appending an additional // error handler to EventEmitter doesn't affect the existing error // handlers anyhow, so should be safe. client.on("error", () => {}); }) .on("error", (error) => // Having this hook prevents node from crashing. this.logSwallowedError({ where: 'Pool.on("error")', error, elapsed: null, importance: "low", }), ); } async acquireConn(): Promise<PgClientConn> { const conn: PgClientConn = await this.pool.connect(); const connReleaseOrig = conn.release.bind(conn); conn.release = (arg) => { // Manage maxConnLifetimeMs manually since it's not supported by the // vanilla node-postgres. const needClose = !!(conn.closeAt && Date.now() > conn.closeAt); return connReleaseOrig(arg !== undefined ? arg : needClose); }; return conn; } poolStats(): ClientQueryLoggerProps["poolStats"] { return { totalConns: this.pool.totalCount, idleConns: this.pool.idleCount, queuedReqs: this.pool.waitingCount, }; } address(): string { const { host, port, database } = this.options.config; return ( host + (port ? `:${port}` : "") + (database ? `/${database}` : "") + "#" + this.shardName ); } override logSwallowedError(props: SwallowedErrorLoggerProps): void { if (!this.ended.current) { super.logSwallowedError(props); } } async end(): Promise<void> { if (this.ended.current) { return; } this.ended.current = true; this.prewarmTimeout.current && clearTimeout(this.prewarmTimeout.current); this.prewarmTimeout.current = null; return this.pool.end(); } isEnded(): boolean { return this.ended.current; } override prewarm(): void { if (this.prewarmTimeout.current) { // Already scheduled a prewarm, so skipping. return; } if (!this.options.config.min) { return; } const min = Math.min( this.options.config.min, this.options.config.max ?? Infinity, this.pool.totalCount + (maybeCall(this.options.prewarmIntervalStep) || 1), ); const toPrewarm = min - this.pool.waitingCount; if (toPrewarm > 0) { const startTime = performance.now(); range(toPrewarm).forEach(() => runInVoid( this.pool.query(maybeCall(this.options.prewarmQuery)).catch((error) => this.logSwallowedError({ where: `${this.constructor.name}.prewarm`, error, elapsed: Math.round(performance.now() - startTime), importance: "normal", }), ), ), ); } this.prewarmTimeout.current = setTimeout( () => { this.prewarmTimeout.current = null; this.prewarm(); }, Math.round( maybeCall(this.options.prewarmIntervalMs) * jitter(maybeCall(this.options.prewarmIntervalJitter)), ), ).unref(); } }