Skip to content

feat(Database): Support for reader DB instance for all read operations - #956

Open
MonishJuspay wants to merge 1 commit into
juspay:releasefrom
MonishJuspay:phase-1-reader-db-support
Open

feat(Database): Support for reader DB instance for all read operations#956
MonishJuspay wants to merge 1 commit into
juspay:releasefrom
MonishJuspay:phase-1-reader-db-support

Conversation

@MonishJuspay

@MonishJuspay MonishJuspay commented Jul 25, 2026

Copy link
Copy Markdown
Contributor
  • Ensures reducing load on the primary DB instance to avoid downtime due to heavy analytic queries
  • Faster retrievable of data for read operations as reader DB has no overhead of writing ,it just replicates from the primary DB
  • Fallback prevents even if the reader_db_instance connection fails, fallsback to the primary instance at the start

Summary by CodeRabbit

  • New Features

    • Added support for routing read operations through a dedicated PostgreSQL read replica.
    • Added a separate read-query interface, with automatic fallback to the primary database when no replica is configured.
    • Added configurable read-replica host and port settings.
  • Bug Fixes

    • Improved database pool cleanup and handling when configuration is incomplete or secure credential decryption fails.

- Ensures reducing load on the primary DB instance to avoid downtime due to heavy analytic queries
- Faster retrievable of data for read operations as reader DB has no overhead of writing ,it just replicates from the primary DB
- Fallback prevents even if the reader_db_instance connection fails, fallsback to the primary instance at the start
Copilot AI review requested due to automatic review settings July 25, 2026 11:21
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds PostgreSQL read-replica configuration, separate write/read asyncpg pools with lifecycle handling, and a read-query execution entry point while preserving the existing write-pool alias and query behavior.

Changes

Database read-replica support

Layer / File(s) Summary
Replica configuration and pool contract
app/core/config/static.py, app/database/__init__.py
Adds reader host and port settings, separate pool references, compatibility aliasing, and updated public exports.
Pool initialization and connection lifecycle
app/database/__init__.py
Creates primary and optional reader pools, handles credential validation and decryption failures, routes connection helpers, and safely closes aliased pools.
Read and write query routing
app/database/queries/__init__.py
Documents pool-specific query usage and adds run_read_query through the read connection helper.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Application
  participant run_read_query
  participant get_read_db_connection
  participant read_pool
  Application->>run_read_query: submit SELECT query and values
  run_read_query->>get_read_db_connection: request read connection
  get_read_db_connection->>read_pool: acquire connection
  run_read_query->>read_pool: fetch query results
  read_pool-->>Application: return first result set
Loading

Suggested reviewers: copilot

Poem

I’m a rabbit with a replica tune,
Hopping reads beneath the moon.
Writes stay strong on primary ground,
Read pools fetch results all around.
Two paths bloom where one had been—
Database carrots, neatly split! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding reader database support for read operations.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds infrastructure to support routing read-only database queries to a PostgreSQL read replica (when configured), while preserving a safe fallback to the primary instance when the replica is not configured.

Changes:

  • Introduces separate write_pool (primary) and read_pool (replica/fallback) and adds get_read_db_connection() to acquire read connections.
  • Adds run_read_query() as an explicit read-path query helper alongside the existing write-path helper.
  • Extends static configuration with POSTGRES_READER_HOST / POSTGRES_READER_PORT environment variables.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
app/database/queries/init.py Adds run_read_query() and documents read vs write query entry points.
app/database/init.py Implements read/write pools, connection generators, and coordinated shutdown behavior.
app/core/config/static.py Adds env vars for read-replica host/port configuration.
Comments suppressed due to low confidence (1)

app/database/init.py:135

  • This comment suggests that callers who imported pool directly will get a live pool object. Reassigning pool at runtime does not update previously imported bindings, so the comment should be corrected to avoid implying stronger backward compatibility than Python provides.
        # Keep the legacy ``pool`` alias in sync so callers that imported
        # ``pool`` directly still get a live pool object.
        pool = write_pool

Comment on lines +12 to +16
run_read_query(query, values)
Executes on the READ pool (replica when POSTGRES_READER_HOST is set,
otherwise the primary via the write_pool alias — safe fallback with no
config change required).
Use for plain SELECT queries where a small replication lag is acceptable.
Comment thread app/database/__init__.py
Comment on lines +38 to +40
# ``pool`` is kept as a public alias for ``write_pool`` so that any code
# outside this module that imported ``pool`` directly continues to work.
# ---------------------------------------------------------------------------

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/database/__init__.py`:
- Around line 141-149: Update the replica initialization branch in init_db_pool
around _create_pool so failures creating the configured read pool are caught and
logged, then set read_pool to write_pool as the fallback. Preserve the existing
replica creation path when successful and ensure the fallback aliases the
already initialized primary pool.
- Around line 165-180: The connection generator functions get_db_connection()
and get_read_db_connection() both need explicit async-generator return
annotations. Update each signature in app/database/__init__.py at lines 165-180
and 183-199 to use the appropriate AsyncGenerator type for the yielded database
connection, preserving their existing pool acquisition behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cd90b72a-3f1c-435d-8f64-46f7babe61f8

📥 Commits

Reviewing files that changed from the base of the PR and between e866aed and 991297c.

📒 Files selected for processing (3)
  • app/core/config/static.py
  • app/database/__init__.py
  • app/database/queries/__init__.py

Comment thread app/database/__init__.py
Comment on lines +141 to +149
if POSTGRES_READER_HOST:
read_pool = await _create_pool(
host=POSTGRES_READER_HOST,
port=POSTGRES_READER_PORT or POSTGRES_PORT,
min_size=resolved_min,
max_size=resolved_max,
decrypted_password=decrypted_password,
label="Read",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Fall back to the primary when replica initialization fails.

_create_pool() re-raises its failure, so an unavailable configured replica aborts init_db_pool() rather than setting read_pool = write_pool. This defeats the promised primary fallback and makes read access unavailable. Catch replica-pool creation failures here, log them, and alias the write pool instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/database/__init__.py` around lines 141 - 149, Update the replica
initialization branch in init_db_pool around _create_pool so failures creating
the configured read pool are caught and logged, then set read_pool to write_pool
as the fallback. Preserve the existing replica creation path when successful and
ensure the fallback aliases the already initialized primary pool.

Comment thread app/database/__init__.py
Comment on lines 165 to +180
async def get_db_connection():
"""
Get a database connection from the pool.
Yield a connection from the WRITE pool (primary).

Use for INSERT / UPDATE / DELETE and any transaction that mixes reads
with writes.
"""
global write_pool
if write_pool is None:
await init_db_pool()

if write_pool is None:
raise RuntimeError("Write pool is not initialised.")

async with write_pool.acquire() as connection:
yield connection

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## git status/stat"
git status --short
git diff --stat || true

echo "## relevant file outline/lines"
wc -l app/database/__init__.py
sed -n '1,240p' app/database/__init__.py | nl -ba

echo "## type annotation/search indicators"
python3 - <<'PY'
import ast
from pathlib import Path

p = Path("app/database/__init__.py")
tree = ast.parse(p.read_text())
for node in ast.walk(tree):
    if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)):
        if node.name in {"get_db_connection", "get_read_db_connection"}:
            print(f"{node.name}:{node.lineno} returns={ast.unparse(node.returns) if node.returns else None}")
PY

Repository: juspay/clairvoyance

Length of output: 277


🏁 Script executed:

#!/bin/bash
set -u

echo "## relevant file lines"
wc -l app/database/__init__.py
awk '{printf "%-5s%s\n", NR, $0}' app/database/__init__.py | sed -n '1,245p'

echo "## AST annotations"
python3 - <<'PY'
import ast
from pathlib import Path

p = Path("app/database/__init__.py")
tree = ast.parse(p.read_text())
for node in ast.walk(tree):
    if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)):
        if node.name in {"get_db_connection", "get_read_db_connection"}:
            returns = ast.unparse(node.returns) if node.returns else None
            print(f"{node.name}:{node.lineno} returns={returns}")
PY

Repository: juspay/clairvoyance

Length of output: 9580


Add return annotations to both connection generators.

  • get_db_connection() currently lacks any return annotation.
  • get_read_db_connection() also lacks any return annotation.

Annotate both with the appropriate async-generator return type so the required signature hints are complete.

📍 Affects 1 file
  • app/database/__init__.py#L165-L180 (this comment)
  • app/database/__init__.py#L183-L199
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/database/__init__.py` around lines 165 - 180, The connection generator
functions get_db_connection() and get_read_db_connection() both need explicit
async-generator return annotations. Update each signature in
app/database/__init__.py at lines 165-180 and 183-199 to use the appropriate
AsyncGenerator type for the yielded database connection, preserving their
existing pool acquisition behavior.

Source: Coding guidelines

@amreetkhuntia

Copy link
Copy Markdown
Contributor

check build

return []


async def run_read_query(query_text: str, values: List[Any]) -> List[asyncpg.Record]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 [MAJOR — the reader is never used; PR delivers zero read-offloading

run_read_query / get_read_db_connection (defined here) have zero callers anywhere in app/ (verified by grep). Every accessor still routes through run_parameterized_queryget_db_connection → the primary. So with POSTGRES_READER_HOST set, the read pool opens connections and is then never queried — all reads still hit the primary. The PR title ("read operations routed to reader") is not realized.

Fix: migrate read-only accessors to run_read_query (at least one) before this is useful — otherwise this is dead plumbing.

@narsimhaReddyJuspay

Copy link
Copy Markdown
Contributor

PR #956feat(Database): reader DB instance for all read operations

Verdict: request changes — 1 major. The plumbing is correct but the feature is inert.

🟥 Major (inline)

  • The reader is never usedrun_read_query/get_read_db_connection have zero callers; all reads still go to the primary. Migrate read accessors before this delivers anything. (queries/__init__.py:46)

🟧 Also worth fixing

  • Replica pool is opened in every per-call bot subprocess (bot_runner.init_db_pool), doubling DB connections (primary+replica) per concurrent call with no benefit (since the replica is never queried). Gate reader-pool creation to the API pod (e.g. an INIT_READ_POOL opt-in), not the voice subprocesses.

✅ Verified clean (the plumbing itself)

  • Two-pool setup, _create_pool, fail-open aliasing (read_pool = write_pool when POSTGRES_READER_HOST unset — identical pre-PR behavior), and the close_db_pool double-close guard are all correct.
  • No write-to-reader leak — because nothing routes to the replica; INSERT/UPDATE … RETURNING, SELECT … FOR UPDATE, and transactions all go through run_parameterized_query (primary). No keyword-inspection misrouting.
  • 🟨 Minor: the pool = write_pool public alias is re-bound manually (not automatic) — drop it (no consumer needs the bare pool symbol) or make it a module __getattr__.

@murdore

murdore commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Reviewed at head 991297c4fecb187d25b71d0960953a142dc35e40. Three points before this merges:

1. Confirming narsimhaReddyJuspay's open MAJOR — the reader is currently a no-op. Independently re-verified via a repo-wide GitHub code search: run_read_query (app/database/queries/__init__.py:46) and get_read_db_connection (app/database/__init__.py:183) have zero callers anywhere else in app/ — 0 results for both symbols outside their own definitions, vs. 25 hits for the existing run_parameterized_query. So with POSTGRES_READER_HOST set, the read pool opens connections against the replica but every current read still goes through run_parameterized_queryget_db_connection → the primary. Matches the PR title in plumbing only; functionally inert until at least one accessor is migrated. Worth either scoping the PR description to "plumbing only, migration follow-up in a separate PR" or wiring one real read accessor here so the feature does something on merge.

2. Confirming coderabbitai's open Major — replica-init failure isn't actually a fallback. app/database/__init__.py:140-149: when POSTGRES_READER_HOST is set but the replica is unreachable, _create_pool() re-raises (by its own contract), and that exception propagates straight out of init_db_pool() — it never reaches the else: read_pool = write_pool branch (line 153). The module docstring promises "Falls back to write_pool ... when the env var is absent," but that's only true for not-configured; a misconfigured/unreachable replica currently takes down pool init entirely instead of degrading to primary.

3. New — close_db_pool()'s two-pool close sequence can silently skip closing the write pool. app/database/__init__.py:217-237:

if read_pool is not None and read_pool is not write_pool:
    try:
        await read_pool.close()
        ...
    except Exception as e:
        logger.error(f"Failed to close read pool: {e}")
        raise
    finally:
        read_pool = None

if write_pool is not None:
    try:
        await write_pool.close()

If read_pool.close() raises, the except re-raises immediately and the function returns before ever reaching the write_pool block below it — so the primary pool is never closed. Before this PR there was only one pool, so this ordering hazard didn't exist. app/main.py's shutdown lifespan calls await close_db_pool() with no surrounding try/except, so this also blocks the close_redis_connections() call right after it — unlike the dispatcher-stop and chat_cancel_bus.stop_subscriber() calls earlier in that same shutdown function, which already follow a catch-log-and-continue pattern rather than re-raising. Suggest mirroring that existing convention here: log-and-continue on each pool's close failure independently instead of re-raising, so one pool's shutdown error can't prevent the other's.

Not raising any test-coverage/design nits beyond these — this is backend-only Python, no UI surface.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants