Skip to content

Commit bbe6992

Browse files
committed
feat: route read-only queries to an optional replica
1 parent dd3b859 commit bbe6992

6 files changed

Lines changed: 82 additions & 66 deletions

File tree

docs/db/schema.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ The schema enforces eight invariants (I1–I8), verified by
8484
be declared in Drizzle's metadata (`schema.ts`). The test suite detects drift
8585
between these two representations.
8686

87+
## Read replica routing
88+
89+
Set `POSTGRES_READ_REPLICA_CONNECTION_STRING` to route the read-only analytics,
90+
transactions, and indexed-data handlers through a separate pool. When unset,
91+
`readDb` is an alias for the primary database and behavior is unchanged. The
92+
write and read-after-write-sensitive routes continue using `db` so callers do
93+
not observe replica lag immediately after a mutation. `getReadPoolStats()`
94+
reports the active read pool without exposing connection details.
95+
8796
## Tables
8897

8998
| Table | SQL Name | Description |

src/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export const EnvSchema = z
2323
/** Database connection string for indexed data (required for startup and health checks). */
2424
POSTGRES_CONNECTION_STRING: z.string().url(),
2525

26+
/** Optional read-only replica; primary is used when unset. */
27+
POSTGRES_READ_REPLICA_CONNECTION_STRING: z.string().url().optional(),
28+
2629
// ═════════════════════════════════════════════════════════════════════
2730
// OPTIONAL — fall back to documented defaults when unset.
2831
// ═════════════════════════════════════════════════════════════════════

src/db/index.ts

Lines changed: 42 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -23,47 +23,42 @@ function maskConnectionString(connectionString: string): string {
2323
}
2424
}
2525

26-
// Create connection pool with proper error handling.
27-
let pool: Pool;
28-
try {
29-
const connectionString = env.POSTGRES_CONNECTION_STRING;
30-
31-
if (!connectionString || typeof connectionString !== "string") {
32-
console.warn("[db] POSTGRES_CONNECTION_STRING not set, database features will be unavailable");
33-
pool = new Pool({
34-
connectionString: "postgresql://localhost:5432/stellopay_indexer",
35-
...poolTuning,
26+
function createPool(connectionString: string | undefined, label: string, tuning = poolTuning): Pool {
27+
try {
28+
if (!connectionString) console.warn(`[db] ${label} connection string not set, using local fallback`);
29+
const url = new URL(connectionString ?? "postgresql://localhost:5432/stellopay_indexer");
30+
if (url.password === null || url.password === undefined) url.password = "";
31+
const createdPool = new Pool({ connectionString: url.toString(), ...tuning });
32+
createdPool.on("error", (error: Error & { code?: string }) => {
33+
console.error(`[db] Unexpected ${label} pool error`, {
34+
message: error.message,
35+
code: error.code,
36+
stack: error.stack,
37+
});
3638
});
37-
} else {
38-
const url = new URL(connectionString);
39-
if (url.password === null || url.password === undefined) {
40-
url.password = "";
41-
}
42-
43-
pool = new Pool({
44-
connectionString: url.toString(),
45-
...poolTuning,
39+
return createdPool;
40+
} catch (error) {
41+
console.error(`[db] Failed to initialize ${label} connection pool`, {
42+
message: error instanceof Error ? error.message : String(error),
43+
});
44+
return new Pool({
45+
connectionString: "postgresql://localhost:5432/stellopay_indexer",
46+
...tuning,
4647
});
4748
}
48-
} catch (error) {
49-
console.error("[db] Failed to initialize connection pool", {
50-
message: error instanceof Error ? error.message : String(error),
51-
});
52-
pool = new Pool({
53-
connectionString: "postgresql://localhost:5432/stellopay_indexer",
54-
...poolTuning,
55-
});
5649
}
5750

58-
pool.on("error", (error: Error & { code?: string }) => {
59-
console.error("[db] Unexpected pool error", {
60-
message: error.message,
61-
code: error.code,
62-
stack: error.stack,
63-
});
64-
});
51+
const pool = createPool(env.POSTGRES_CONNECTION_STRING, "primary");
52+
const readPool = env.POSTGRES_READ_REPLICA_CONNECTION_STRING
53+
? createPool(env.POSTGRES_READ_REPLICA_CONNECTION_STRING, "read replica", {
54+
...poolTuning,
55+
max: Math.max(1, Math.floor(env.DB_POOL_MAX / 2)),
56+
})
57+
: null;
6558

6659
export const db = drizzle(pool, { schema });
60+
/** Read-only queries use the replica when configured, otherwise the primary. */
61+
export const readDb = drizzle(readPool ?? pool, { schema });
6762
export { schema };
6863

6964
/** Current utilization counters for the shared Postgres connection pool. */
@@ -81,14 +76,23 @@ export interface PoolStats {
8176
* details are included, and reading the snapshot does not acquire a client.
8277
*/
8378
export function getPoolStats(): PoolStats {
84-
const total = pool.totalCount;
85-
const idle = pool.idleCount;
79+
return getStatsForPool(pool);
80+
}
81+
82+
/** Returns the replica pool snapshot, or the primary snapshot when disabled. */
83+
export function getReadPoolStats(): PoolStats {
84+
return getStatsForPool(readPool ?? pool);
85+
}
86+
87+
function getStatsForPool(activePool: Pool): PoolStats {
88+
const total = activePool.totalCount;
89+
const idle = activePool.idleCount;
8690

8791
return {
8892
total,
8993
idle,
9094
active: total - idle,
91-
waiting: pool.waitingCount,
95+
waiting: activePool.waitingCount,
9296
};
9397
}
9498

@@ -133,6 +137,7 @@ export async function waitForDbReadiness(): Promise<void> {
133137
export async function closePool(): Promise<void> {
134138
console.log("[db] Closing Postgres connection pool...");
135139
await pool.end();
140+
if (readPool && readPool !== pool) await readPool.end();
136141
console.log("[db] Postgres connection pool closed.");
137142
}
138143

src/routes/analytics.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createHash } from "node:crypto";
22
import { Router } from "express";
33
import { z } from "zod";
4-
import { db, schema } from "../db/index.js";
4+
import { readDb, schema } from "../db/index.js";
55
import { asc, eq, and, gt, gte, lte, or, sql } from "drizzle-orm";
66
import { StarknetAddress } from "../utils/validation.js";
77
import { DEFAULT_TOKEN_DECIMALS } from "../utils/codec.js";
@@ -287,7 +287,7 @@ analyticsRouter.get("/analytics/:user_address", async (req, res, next) => {
287287
: undefined;
288288

289289
const whereCondition = cursorFilter ? and(baseFilter, cursorFilter) : baseFilter;
290-
const query = db
290+
const query = readDb
291291
.select({
292292
id: schema.payments.id,
293293
createdAt: schema.payments.createdAt,
@@ -327,7 +327,7 @@ analyticsRouter.get("/analytics/:user_address", async (req, res, next) => {
327327
: undefined;
328328

329329
const whereCondition = cursorFilter ? and(baseFilter, cursorFilter) : baseFilter;
330-
const query = db
330+
const query = readDb
331331
.select({
332332
id: schema.escrowEvents.id,
333333
createdAt: schema.escrowEvents.createdAt,
@@ -369,7 +369,7 @@ analyticsRouter.get("/analytics/:user_address", async (req, res, next) => {
369369
: undefined;
370370

371371
const whereCondition = cursorFilter ? and(baseFilter, cursorFilter) : baseFilter;
372-
const query = db
372+
const query = readDb
373373
.select({
374374
id: schema.agreementEvents.id,
375375
createdAt: schema.agreementEvents.createdAt,

src/routes/indexed.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Router } from "express";
22
import { z } from "zod";
3-
import { db, schema } from "../db/index.js";
3+
import { readDb, schema } from "../db/index.js";
44
import { eq, and, or, desc } from "drizzle-orm";
55
import { StarknetAddress, AgreementId, parsePagination } from "../utils/validation.js";
66
import { defaults, env } from "../config.js";
@@ -238,7 +238,7 @@ async function resolveCheckpoint(): Promise<{
238238
checkpointBlock: number;
239239
records: Array<{ blockNumber: unknown }>;
240240
}> {
241-
const records = await db
241+
const records = await readDb
242242
.select({ blockNumber: schema.agreementEvents.blockNumber })
243243
.from(schema.agreementEvents)
244244
.orderBy(desc(schema.agreementEvents.blockNumber))
@@ -393,7 +393,7 @@ indexedRouter.get(
393393
const { limit, offset } = parsePagination(req.query);
394394

395395
const [agreements, employeeAgreements] = await Promise.all([
396-
db
396+
readDb
397397
.select()
398398
.from(schema.agreements)
399399
.where(
@@ -409,7 +409,7 @@ indexedRouter.get(
409409
.limit(limit)
410410
.offset(offset),
411411

412-
db
412+
readDb
413413
.select({
414414
agreement: schema.agreements,
415415
})
@@ -487,7 +487,7 @@ indexedRouter.get("/indexed/agreement/:contract_address/:agreement_id", async (r
487487
}
488488
const agreementId = AgreementId.parse(req.params.agreement_id);
489489

490-
const agreement = await db
490+
const agreement = await readDb
491491
.select()
492492
.from(schema.agreements)
493493
.where(
@@ -504,23 +504,23 @@ indexedRouter.get("/indexed/agreement/:contract_address/:agreement_id", async (r
504504
}
505505

506506
const [events, payments, milestones, employees, escrowEvents] = await Promise.all([
507-
db.select().from(schema.agreementEvents)
507+
readDb.select().from(schema.agreementEvents)
508508
.where(eq(schema.agreementEvents.agreementId, agreementId))
509509
.orderBy(desc(schema.agreementEvents.blockNumber)).limit(MAX_INTERNAL_LIMIT),
510510

511-
db.select().from(schema.payments)
511+
readDb.select().from(schema.payments)
512512
.where(eq(schema.payments.agreementId, agreementId))
513513
.orderBy(desc(schema.payments.blockNumber)).limit(MAX_INTERNAL_LIMIT),
514514

515-
db.select().from(schema.milestones)
515+
readDb.select().from(schema.milestones)
516516
.where(eq(schema.milestones.agreementId, agreementId))
517517
.orderBy(schema.milestones.milestoneId).limit(MAX_INTERNAL_LIMIT),
518518

519-
db.select().from(schema.employees)
519+
readDb.select().from(schema.employees)
520520
.where(eq(schema.employees.agreementId, agreementId))
521521
.orderBy(schema.employees.employeeIndex).limit(MAX_INTERNAL_LIMIT),
522522

523-
db.select().from(schema.escrowEvents)
523+
readDb.select().from(schema.escrowEvents)
524524
.where(eq(schema.escrowEvents.agreementId, agreementId))
525525
.orderBy(desc(schema.escrowEvents.blockNumber)).limit(MAX_INTERNAL_LIMIT),
526526
]);
@@ -586,7 +586,7 @@ indexedRouter.get("/indexed/payments/user/:user_address", async (req, res, next)
586586
const userAddress = StarknetAddress.parse(req.params.user_address);
587587
const { limit, offset } = parsePagination(req.query);
588588

589-
const payments = await db
589+
const payments = await readDb
590590
.select()
591591
.from(schema.payments)
592592
.where(or(eq(schema.payments.from, userAddress), eq(schema.payments.to, userAddress)))
@@ -642,7 +642,7 @@ indexedRouter.get(
642642
}
643643
const agreementId = AgreementId.parse(req.params.agreement_id);
644644

645-
const escrowEvents = await db
645+
const escrowEvents = await readDb
646646
.select()
647647
.from(schema.escrowEvents)
648648
.where(
@@ -704,4 +704,3 @@ indexedRouter.get(
704704
);
705705

706706
export default indexedRouter;
707-

src/routes/transactions.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
*/
2727
import { Router } from "express";
2828
import { z } from "zod";
29-
import { db, schema } from "../db/index.js";
29+
import { readDb, schema } from "../db/index.js";
3030
import { eq, and, or, desc, gte, lte, inArray, sql, count } from "drizzle-orm";
3131
import { agreementContract } from "../starknet/client.js";
3232
import { toHexString } from "../utils/codec.js";
@@ -857,31 +857,31 @@ async function fetchAndBuildTransactions(
857857
milestoneEventsData,
858858
] = await Promise.all([
859859
// ── counts ────────────────────────────────────────────────────────
860-
db
860+
readDb
861861
.select({ count: count() })
862862
.from(schema.payments)
863863
.where(conds.payments),
864-
db
864+
readDb
865865
.select({ count: count() })
866866
.from(schema.escrowEvents)
867867
.where(conds.escrowEvents),
868-
db
868+
readDb
869869
.select({ count: count() })
870870
.from(schema.agreementEvents)
871871
.innerJoin(
872872
schema.agreements,
873873
eq(schema.agreementEvents.agreementId, schema.agreements.id),
874874
)
875875
.where(conds.agreementEvents),
876-
db
876+
readDb
877877
.select({ count: count() })
878878
.from(schema.employees)
879879
.leftJoin(
880880
schema.agreements,
881881
eq(schema.employees.agreementId, schema.agreements.id),
882882
)
883883
.where(conds.employees),
884-
db
884+
readDb
885885
.select({ count: count() })
886886
.from(schema.milestones)
887887
.leftJoin(
@@ -890,19 +890,19 @@ async function fetchAndBuildTransactions(
890890
)
891891
.where(conds.milestones),
892892
// ── data ──────────────────────────────────────────────────────────
893-
db
893+
readDb
894894
.select()
895895
.from(schema.payments)
896896
.where(conds.payments)
897897
.orderBy(desc(schema.payments.createdAt), desc(schema.payments.id))
898898
.limit(queryLimit),
899-
db
899+
readDb
900900
.select()
901901
.from(schema.escrowEvents)
902902
.where(conds.escrowEvents)
903903
.orderBy(desc(schema.escrowEvents.createdAt), desc(schema.escrowEvents.id))
904904
.limit(queryLimit),
905-
db
905+
readDb
906906
.select({
907907
id: schema.agreementEvents.id,
908908
agreementId: schema.agreementEvents.agreementId,
@@ -923,7 +923,7 @@ async function fetchAndBuildTransactions(
923923
.where(conds.agreementEvents)
924924
.orderBy(desc(schema.agreementEvents.createdAt), desc(schema.agreementEvents.id))
925925
.limit(queryLimit),
926-
db
926+
readDb
927927
.select({
928928
id: schema.employees.id,
929929
agreementId: schema.employees.agreementId,
@@ -945,7 +945,7 @@ async function fetchAndBuildTransactions(
945945
.where(conds.employees)
946946
.orderBy(desc(schema.employees.createdAt), desc(schema.employees.id))
947947
.limit(queryLimit),
948-
db
948+
readDb
949949
.select({
950950
id: schema.milestones.id,
951951
agreementId: schema.milestones.agreementId,
@@ -995,7 +995,7 @@ async function fetchAndBuildTransactions(
995995

996996
const escrowAgreements =
997997
escrowAgreementIds.length > 0
998-
? await db
998+
? await readDb
999999
.select({
10001000
id: schema.agreements.id,
10011001
token: schema.agreements.token,

0 commit comments

Comments
 (0)