UNPKG

@clickup/ent-framework

Version:

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

212 lines (191 loc) 8.04 kB
import { Memoize } from "fast-typescript-memoize"; import defaults from "lodash/defaults"; import type { MaybeCallable, PickPartial } from "../internal/misc"; import { objectId } from "../internal/objectId"; import type { Table } from "../types"; import { Batcher } from "./Batcher"; import type { ClientErrorKind, ClientErrorPostAction } from "./ClientError"; import type { Loggers, SwallowedErrorLoggerProps } from "./Loggers"; import type { QueryAnnotation } from "./QueryAnnotation"; import type { Runner } from "./Runner"; import type { Schema } from "./Schema"; import type { ShardNamer } from "./ShardNamer"; import type { TimelineManager } from "./TimelineManager"; /** * Options for Client constructor. */ export interface ClientOptions { /** Name of the Client; used for logging. */ name: string; /** Info on how to build/parse Shard names. If not set, then Cluster injects * its own ShardNamer here right after creating a Client instance. */ shardNamer?: ShardNamer | null; /** Loggers to be called at different stages. Client code calls into them. * Also, Cluster injects its own loggers here, in addition to the provided * ones (if any). */ loggers?: Loggers | null; /** If passed, there will be an artificial queries accumulation delay while * batching the requests. Default is 0 (turned off). Passed to * Batcher#batchDelayMs. */ batchDelayMs?: MaybeCallable<number>; } /** * Role of the Client as reported after the last successful query. If we know * for sure that the Client is a master or a replica, the role will be "master" * or "replica" correspondingly. If no queries were run by the Client yet (i.e. * we don't know the role for sure), the role is assigned to "unknown". */ export type ClientRole = "master" | "replica" | "unknown"; /** * An information about Client's connection related issue. */ export interface ClientConnectionIssue { timestamp: Date; cause: unknown; postAction: ClientErrorPostAction; kind: ClientErrorKind; comment: string; } /** * Input for Client#ping(). */ export interface ClientPingInput { execTimeMs: number; isWrite: boolean; annotation: QueryAnnotation; } /** * Client is a Shard name aware abstraction which sends an actual query and * tracks the master/replica timeline. The concrete query sending implementation * (including required arguments) is up to the derived classes. */ export abstract class Client { /** Default values for the constructor options. */ static readonly DEFAULT_OPTIONS: Required<PickPartial<ClientOptions>> = { shardNamer: null, loggers: null, batchDelayMs: 0, }; /** Client configuration options. */ readonly options: Required<ClientOptions>; /** Each Client may be bound to some Shard, so the queries executed via it * will be namespaced to this Shard. E.g. in relational databases, Shard name * may be a namespace (or schema) name (or "public" if the Client wasn't * created by withShard() method). */ abstract readonly shardName: string; /** Tracks the master/replica replication timeline position. Shared across all * the Clients within the same Island. */ abstract readonly timelineManager: TimelineManager; /** * Represents the full destination address this Client is working with. * Depending on the implementation, it may include hostname, port number, * database name, shard name etc. It is required that the address is stable * enough to be able to cache some destination database related metadata (e.g. * shardNos) based on that address. */ abstract address(): string; /** * Gracefully closes the connections to let the caller destroy the Client. The * pending queries are awaited to finish before returning. The Client becomes * unusable after calling this method: you should not send queries to it. */ abstract end(): Promise<void>; /** * Returns true if the Client is ended and can't be used anymore. */ abstract isEnded(): boolean; /** * Returns all Shard numbers discoverable via the connection to the Client's * database. */ abstract shardNos(): Promise<readonly number[]>; /** * Sends a read or write test query to the server. Tells the server to sit and * wait for at least the provided number of milliseconds. */ abstract ping(input: ClientPingInput): Promise<void>; /** * Creates a new Client which is namespaced to the provided Shard number. The * new Client will share the same connection pool with the parent's Client. */ abstract withShard(no: number): this; /** * Returns the Client's role reported after the last successful query. Master * and replica roles may switch online unpredictably, without reconnecting, so * we only know the role after a query. */ abstract role(): ClientRole; /** * Returns a non-nullable value if the Client couldn't connect to the server * (or it could, but the load balancer reported the remote server as not * working), so it should ideally be removed from the list of active replicas * until e.g. the next discovery query to it (or any query) succeeds. */ abstract connectionIssue(): ClientConnectionIssue | null; /** * Calls swallowedErrorLogger() doing some preliminary amendment. */ protected logSwallowedError(props: SwallowedErrorLoggerProps): void { this.options.loggers?.swallowedErrorLogger({ ...props, where: `${this.constructor.name}(${this.options.name}): ${props.where}`, }); } /** * Initializes an instance of Client. */ constructor(options: ClientOptions) { this.options = defaults({}, options, Client.DEFAULT_OPTIONS); } /** * Batcher is per-Client per-query-type * per-table-name-and-shape-and-disableBatching: * * - Per-Client means that batchers are removed as soon as the Client is * removed, i.e. the Client owns all the batchers for all tables. * - Per-query-type means that the batcher for a SELECT query is different * from the batcher for an INSERT query (obviously). * - Per-table-name-and-shape-and-disableBatching means that each table has * its own set of batchers (obviously). Also, some queries may be complex * (like UPDATE), so the batcher also depends on the "shape" - the list of * fields we're updating. Plus, for some inputs, we want to disable batching * at all - that produces a separate Batcher instance. * * Also, for every Batcher, there is exactly one Runner (which knows how to * build the actual query in the context of the current Client). Batchers are * generic (like DataLoader, but more general), and Runners are very custom to * the query (and are private to these queries). * * All that means that in a 1000-Shard 20-table Cluster we'll eventually have * 1000x20x8 Batchers/Runners (assuming we have 8 different operations). */ @Memoize( (QueryClass, schema, additionalShape, _, disableBatching) => `${objectId(QueryClass)}:${schema.hash}:${additionalShape}:${disableBatching}`, ) batcher<TInput, TOutput, TTable extends Table>( _QueryClass: Function, _schema: Schema<TTable>, _additionalShape: string, disableBatching: boolean, runnerCreator: () => Runner<TInput, TOutput>, ): Batcher<TInput, TOutput> { // At the moment, Runner doesn't depend on the Client. So theoretically we // could share the same Runner across multiple Batchers (and multiple // Clients) to save memory (and inject the Client via Runner.run*() // methods). But we don't do all that right now. const runner = runnerCreator(); return new Batcher<TInput, TOutput>( runner, this.options.batchDelayMs, disableBatching, ); } /** * A convenience method to put connections prewarming logic to. The idea is to * keep the needed number of open connections and also, in each connection, * minimize the time which the very 1st query will take (e.g. pre-cache * full-text dictionaries). */ prewarm(): void {} }