UNPKG

@clickup/ent-framework

Version:

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

155 lines (135 loc) 4.91 kB
import { maybeCall } from "../internal/misc"; import { type Client } from "./Client"; import type { Island } from "./Island"; import type { Query } from "./Query"; import type { QueryAnnotation, WhyClient } from "./QueryAnnotation"; import type { Timeline } from "./Timeline"; /** * Master freshness: reads always go to master. */ export const MASTER = Symbol("MASTER"); /** * Stale replica freshness: reads always go to a replica, even if it's stale. */ export const STALE_REPLICA = Symbol("STALE_REPLICA"); /** * Shard lives within an Island with one master and N replicas. */ export class Shard<TClient extends Client> { private shardClients = new WeakMap<TClient, TClient>(); /** The last known Island number where this Shard was discovered. It may be * out of date after the Shard is moved, and also it may be null in case there * was no discovery happened yet. */ public readonly lastKnownIslandNo: number | null = null; constructor( /** Shard number. */ public readonly no: number, /** A middleware to wrap queries with. It's responsible for locating the * right Island and retrying the call to body() (i.e. failed queries) in * case e.g. a shard is moved to another Island. */ public readonly runOnShard: <TRes>( shardNo: number, body: (island: Island<TClient>, attempt: number) => Promise<TRes>, onAttemptError?: (error: unknown, attempt: number) => void, ) => Promise<TRes>, ) {} /** * Chooses the right Client to be used for this Shard. We don't memoize, * because the Shard may relocate to another Island during re-discovery. */ async client( timeline: Timeline | typeof MASTER | typeof STALE_REPLICA, ): Promise<TClient> { const [client] = await this.runOnShard(this.no, async (island) => this.clientImpl(island, timeline, undefined), ); return client; } /** * Runs a query after choosing the right Client (destination connection, * Shard, annotation etc.) */ async run<TOutput>( query: Query<TOutput>, annotation: QueryAnnotation, timeline: Timeline, freshness: null | typeof MASTER | typeof STALE_REPLICA, onAttemptError?: (error: unknown, attempt: number) => void, ): Promise<TOutput> { return this.runOnShard( this.no, async (island, attempt) => { const [client, whyClient] = await this.clientImpl( island, freshness ?? timeline, query.IS_WRITE ? true : undefined, ); // Throws if e.g. the Shard was there by the moment we got its client // above, but it probably disappeared (during migration) and appeared on // some other Island. const res = await query.run(client, { ...annotation, whyClient, attempt: annotation.attempt + attempt, }); if (query.IS_WRITE && freshness !== STALE_REPLICA) { timeline.setPos( await client.timelineManager.currentPos(), maybeCall(client.timelineManager.maxLagMs), ); } return res; }, onAttemptError, ); } /** * Throws if this Shard does not exist, or its Island is down, or something * else is wrong with it. */ async assertDiscoverable(): Promise<void> { await this.client(MASTER); } /** * An extended Client selection logic. There are multiple reasons (8+ in total * so far) why a master or a replica may be chosen to send the query to. We * don't @Memoize, because the Shard may relocate to another Island during * re-discovery, so we have to run this logic every time. */ private async clientImpl( island: Island<TClient>, timeline: Timeline | typeof MASTER | typeof STALE_REPLICA, isWrite: true | undefined, ): Promise<[client: TClient, whyClient: WhyClient]> { if (isWrite) { return [this.withShard(island.master()), "master-bc-is-write"]; } if (timeline === MASTER) { return [this.withShard(island.master()), "master-bc-master-freshness"]; } const replica = island.replica(); if (replica.role() !== "replica") { return [this.withShard(replica), "master-bc-no-replicas"]; } if (timeline === STALE_REPLICA) { return [this.withShard(replica), "replica-bc-stale-replica-freshness"]; } const isCaughtUp = timeline.isCaughtUp( await replica.timelineManager.currentPos(), ); return isCaughtUp ? [this.withShard(replica), isCaughtUp] : [this.withShard(island.master()), "master-bc-replica-not-caught-up"]; } /** * Returns a Shard-aware Client from an Island Client. */ private withShard(client: TClient): TClient { let shardClient = this.shardClients.get(client); if (!shardClient) { shardClient = client.withShard(this.no); this.shardClients.set(client, shardClient); } return shardClient; } }