Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions backend/config/__tests__/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,26 @@ describe('database config', () => {
expect(poolConfig.max).toBe(25);
expect(poolConfig.database).toBe(base.database);
});

it('loads primary from DATABASE_URL', () => {
const config = loadDatabaseConfig({
DATABASE_URL: 'postgresql://app:secret@db.internal:6543/subtrackr?sslmode=require',
});
expect(config.primary.host).toBe('db.internal');
expect(config.primary.port).toBe(6543);
expect(config.primary.user).toBe('app');
expect(config.primary.password).toBe('secret');
expect(config.primary.ssl).toEqual({ rejectUnauthorized: true });
});

it('loads replicas from DATABASE_READ_URLS', () => {
const config = loadDatabaseConfig({
DATABASE_READ_URLS:
'postgresql://app:p@r1.internal:5432/subtrackr,postgresql://app:p@r2.internal:5433/subtrackr',
});
expect(config.replicas).toEqual([
{ name: 'replica-1', host: 'r1.internal', port: 5432 },
{ name: 'replica-2', host: 'r2.internal', port: 5433 },
]);
});
});
62 changes: 56 additions & 6 deletions backend/config/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
*
* Environment variables (primary):
* DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD, DB_SSL
* DATABASE_URL – optional postgres:// URL (overrides discrete DB_* host fields)
*
* Read replicas (optional — comma-separated host:port pairs):
* Read replicas (optional — comma-separated host:port pairs OR URLs):
* DB_READ_REPLICAS – e.g. "replica-1.internal:6432,replica-2.internal:6433"
* DATABASE_READ_URLS – e.g. "postgres://u:p@r1:5432/db,postgres://u:p@r2:5432/db"
* DB_REPLICA_POOL_SIZE – PgBouncer pool size per replica (default: 25)
*
* Replication lag thresholds (milliseconds):
Expand All @@ -17,6 +19,10 @@
*/

import type { PoolConfig } from '../shared/db/connectionPool';
import {
ConnectionStringRotator,
type ParsedConnectionString,
} from '../shared/db/connectionStringRotation';

export interface ReplicaEndpoint {
/** Logical name used in metrics labels (replica-1, replica-2, …). */
Expand Down Expand Up @@ -77,26 +83,70 @@ function parseReplicaEndpoints(raw: string | undefined): ReplicaEndpoint[] {
});
}

function poolDefaults(env: NodeJS.ProcessEnv): Pick<
Required<PoolConfig>,
'max' | 'idleTimeoutMillis' | 'connectionTimeoutMillis' | 'statementTimeout'
> {
return {
max: parsePositiveInt(env.DB_POOL_MAX, 20),
idleTimeoutMillis: parsePositiveInt(env.DB_IDLE_TIMEOUT_MS, 10_000),
connectionTimeoutMillis: parsePositiveInt(env.DB_CONNECTION_TIMEOUT_MS, 30_000),
statementTimeout: parsePositiveInt(env.DB_STATEMENT_TIMEOUT_MS, 30_000),
};
}

function primaryFromParsed(
parsed: ParsedConnectionString,
env: NodeJS.ProcessEnv,
): Required<PoolConfig> {
return {
...poolDefaults(env),
host: parsed.host,
port: parsed.port,
database: parsed.database,
user: parsed.user,
password: parsed.password,
ssl: (parsed.ssl || env.DB_SSL === 'true') ? { rejectUnauthorized: true } : false,
};
}

function buildPrimaryConfig(env: NodeJS.ProcessEnv): Required<PoolConfig> {
if (env.DATABASE_URL?.trim()) {
const rotator = new ConnectionStringRotator({ primaryUrl: env.DATABASE_URL });
const primary = rotator.getPrimary();
if (primary) {
return primaryFromParsed(primary, env);
}
}

return {
...poolDefaults(env),
host: env.DB_HOST?.trim() || 'localhost',
port: parsePositiveInt(env.DB_PORT, 5432),
database: env.DB_NAME?.trim() || 'subtrackr',
user: env.DB_USER?.trim() || 'postgres',
password: env.DB_PASSWORD ?? '',
max: parsePositiveInt(env.DB_POOL_MAX, 20),
idleTimeoutMillis: parsePositiveInt(env.DB_IDLE_TIMEOUT_MS, 10_000),
connectionTimeoutMillis: parsePositiveInt(env.DB_CONNECTION_TIMEOUT_MS, 30_000),
statementTimeout: parsePositiveInt(env.DB_STATEMENT_TIMEOUT_MS, 30_000),
ssl: env.DB_SSL === 'true' ? { rejectUnauthorized: true } : false,
};
}

function resolveReplicas(env: NodeJS.ProcessEnv): ReplicaEndpoint[] {
if (env.DATABASE_READ_URLS?.trim()) {
const rotator = ConnectionStringRotator.fromEnv(env);
return rotator.getReplicas().map((r) => ({
name: r.name,
host: r.host,
port: r.port,
}));
}
return parseReplicaEndpoints(env.DB_READ_REPLICAS);
}

/** Load database configuration from environment variables. */
export function loadDatabaseConfig(env: NodeJS.ProcessEnv = process.env): DatabaseConfig {
return {
primary: buildPrimaryConfig(env),
replicas: parseReplicaEndpoints(env.DB_READ_REPLICAS),
replicas: resolveReplicas(env),
replicaPoolSize: parsePositiveInt(
env.DB_REPLICA_POOL_SIZE,
DEFAULT_DATABASE_CONFIG.replicaPoolSize,
Expand Down
69 changes: 69 additions & 0 deletions backend/elasticsearch/__tests__/replicaRouter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
ElasticsearchReplicaRouter,
createElasticsearchReplicaRouter,
} from '../replicaRouter';
import { loadElasticsearchConfig, loadElasticsearchNodes } from '../config';

describe('elasticsearch replica config', () => {
it('loads primary and replica nodes from env', () => {
const nodes = loadElasticsearchNodes({
ES_PRIMARY_URL: 'https://es-primary:9200',
ES_READ_REPLICA_URLS: 'https://es-r1:9200,https://es-r2:9200',
});
expect(nodes).toEqual([
{ name: 'es-primary', url: 'https://es-primary:9200', role: 'primary' },
{ name: 'es-replica-1', url: 'https://es-r1:9200', role: 'replica' },
{ name: 'es-replica-2', url: 'https://es-r2:9200', role: 'replica' },
]);
});

it('defaults to in-process when no nodes configured', () => {
const config = loadElasticsearchConfig({});
expect(config.nodes).toEqual([]);
expect(config.readWriteSplitting).toBe(true);
expect(config.automaticFailover).toBe(true);
});
});

describe('ElasticsearchReplicaRouter', () => {
it('routes writes to primary and reads to replicas', () => {
const router = createElasticsearchReplicaRouter({
ES_PRIMARY_URL: 'https://es-primary:9200',
ES_READ_REPLICA_URLS: 'https://es-r1:9200,https://es-r2:9200',
});

expect(router.route('write').route).toBe('primary');
expect(router.route('read').route).toBe('replica:es-replica-1');
expect(router.route('read').route).toBe('replica:es-replica-2');
});

it('fails over reads to primary when replicas are down', () => {
const router = new ElasticsearchReplicaRouter(
loadElasticsearchConfig({
ES_PRIMARY_URL: 'https://es-primary:9200',
ES_READ_REPLICA_URLS: 'https://es-r1:9200',
}),
);

router.markFailed('es-replica-1');
const result = router.route('read');
expect(result.route).toBe('failover-primary');
expect(result.failedOver).toBe(true);
expect(result.node?.name).toBe('es-primary');
});

it('supports connection string / node URL rotation', () => {
const router = createElasticsearchReplicaRouter({
ES_PRIMARY_URL: 'https://es-primary:9200',
ES_READ_REPLICA_URLS: 'https://es-r1:9200',
});

const rotated = router.rotateNodes([
{ name: 'es-primary', url: 'https://es-primary-new:9200', role: 'primary' },
{ name: 'es-replica-1', url: 'https://es-r1-new:9200', role: 'replica' },
]);

expect(rotated.getPrimary()?.url).toBe('https://es-primary-new:9200');
expect(rotated.route('read').node?.url).toBe('https://es-r1-new:9200');
});
});
67 changes: 67 additions & 0 deletions backend/elasticsearch/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,21 @@
* In this mobile-first architecture the "cluster" is an in-process index
* backed by AsyncStorage, mirroring a real ES setup so the service layer
* can be swapped for a remote cluster without changing callers.
*
* When remote nodes are configured, reads prefer replica nodes and writes
* go to the primary, with automatic failover when a replica is marked down.
*/

export type ElasticsearchNodeRole = 'primary' | 'replica';

export interface ElasticsearchNode {
/** Logical name used in routing / metrics (es-primary, es-replica-1, …). */
name: string;
/** Node HTTP URL, e.g. https://es-replica-1.internal:9200 */
url: string;
role: ElasticsearchNodeRole;
}

export interface ElasticsearchConfig {
indexName: string;
fuzzyMaxEdits: number;
Expand All @@ -20,6 +33,57 @@ export interface ElasticsearchConfig {
minQueryLength: number;
/** Whether to highlight matching terms in result fields */
highlightEnabled: boolean;
/**
* Optional remote cluster nodes. When empty the in-process index is used.
* Env: ES_PRIMARY_URL + ES_READ_REPLICA_URLS (comma-separated).
*/
nodes: ElasticsearchNode[];
/** Route search/read requests to replica nodes when available. */
readWriteSplitting: boolean;
/** Fail reads over to primary when no healthy replica remains. */
automaticFailover: boolean;
}

function parsePositiveInt(value: string | undefined, fallback: number): number {
if (value === undefined || value === '') return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}

/** Build ES node list from environment variables. */
export function loadElasticsearchNodes(env: NodeJS.ProcessEnv = process.env): ElasticsearchNode[] {
const nodes: ElasticsearchNode[] = [];
const primaryUrl = env.ES_PRIMARY_URL?.trim();
if (primaryUrl) {
nodes.push({ name: 'es-primary', url: primaryUrl, role: 'primary' });
}

const replicaRaw = env.ES_READ_REPLICA_URLS?.trim();
if (replicaRaw) {
replicaRaw
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.forEach((url, index) => {
nodes.push({ name: `es-replica-${index + 1}`, url, role: 'replica' });
});
}

return nodes;
}

export function loadElasticsearchConfig(
env: NodeJS.ProcessEnv = process.env,
overrides: Partial<ElasticsearchConfig> = {},
): ElasticsearchConfig {
return {
...DEFAULT_ES_CONFIG,
nodes: loadElasticsearchNodes(env),
readWriteSplitting: env.ES_READ_WRITE_SPLITTING !== 'false',
automaticFailover: env.ES_AUTOMATIC_FAILOVER !== 'false',
maxResults: parsePositiveInt(env.ES_MAX_RESULTS, DEFAULT_ES_CONFIG.maxResults),
...overrides,
};
}

export const DEFAULT_ES_CONFIG: ElasticsearchConfig = {
Expand All @@ -38,6 +102,9 @@ export const DEFAULT_ES_CONFIG: ElasticsearchConfig = {
analyticsBufferSize: 500,
minQueryLength: 1,
highlightEnabled: true,
nodes: [],
readWriteSplitting: true,
automaticFailover: true,
};

export interface FieldMapping {
Expand Down
107 changes: 107 additions & 0 deletions backend/elasticsearch/replicaRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Elasticsearch read/write node router with automatic failover.
*
* Writes always target the primary node. Reads round-robin across healthy
* replicas and fall back to primary when replicas are unavailable.
*/

import type { ElasticsearchConfig, ElasticsearchNode } from './config';
import { loadElasticsearchConfig } from './config';

export type ElasticsearchRouteKind = 'read' | 'write';

export interface ElasticsearchRouteResult {
node: ElasticsearchNode | null;
/** in-process | primary | replica:<name> | failover-primary */
route: string;
failedOver: boolean;
}

export class ElasticsearchReplicaRouter {
private readonly config: ElasticsearchConfig;
private readonly failed = new Set<string>();
private replicaIndex = 0;

constructor(config: ElasticsearchConfig = loadElasticsearchConfig()) {
this.config = config;
}

getConfig(): ElasticsearchConfig {
return this.config;
}

getPrimary(): ElasticsearchNode | null {
return this.config.nodes.find((n) => n.role === 'primary') ?? null;
}

getReplicas(): ElasticsearchNode[] {
return this.config.nodes.filter((n) => n.role === 'replica');
}

getHealthyReplicas(): ElasticsearchNode[] {
return this.getReplicas().filter((n) => !this.failed.has(n.name));
}

markFailed(name: string): void {
this.failed.add(name);
}

markHealthy(name: string): void {
this.failed.delete(name);
}

/**
* Select a node for the operation. Returns null when no remote nodes are
* configured (callers should use the in-process index).
*/
route(kind: ElasticsearchRouteKind): ElasticsearchRouteResult {
if (this.config.nodes.length === 0) {
return { node: null, route: 'in-process', failedOver: false };
}

if (kind === 'write' || !this.config.readWriteSplitting) {
const primary = this.getPrimary();
return {
node: primary,
route: primary ? 'primary' : 'in-process',
failedOver: false,
};
}

const healthy = this.getHealthyReplicas();
if (healthy.length > 0) {
const selected = healthy[this.replicaIndex % healthy.length]!;
this.replicaIndex = (this.replicaIndex + 1) % healthy.length;
return {
node: selected,
route: `replica:${selected.name}`,
failedOver: false,
};
}

if (this.config.automaticFailover) {
const primary = this.getPrimary();
return {
node: primary,
route: primary ? 'failover-primary' : 'in-process',
failedOver: true,
};
}

return { node: null, route: 'in-process', failedOver: true };
}

/** Rotate remote node URLs (connection string rotation for ES). */
rotateNodes(nodes: ElasticsearchNode[]): ElasticsearchReplicaRouter {
return new ElasticsearchReplicaRouter({
...this.config,
nodes: [...nodes],
});
}
}

export function createElasticsearchReplicaRouter(
env: NodeJS.ProcessEnv = process.env,
): ElasticsearchReplicaRouter {
return new ElasticsearchReplicaRouter(loadElasticsearchConfig(env));
}
Loading