Skip to content

OLS-3221 Add PostgreSQL auto-recovery after DB restart - #2964

Open
sriroopar wants to merge 1 commit into
openshift:mainfrom
sriroopar:postgres-auto-recovery
Open

OLS-3221 Add PostgreSQL auto-recovery after DB restart#2964
sriroopar wants to merge 1 commit into
openshift:mainfrom
sriroopar:postgres-auto-recovery

Conversation

@sriroopar

@sriroopar sriroopar commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Implement background health-check loop, smarter error classification in the @connection decorator, operation timeouts, and enhanced liveness/readiness probes so OLS can recover automatically when the backing PostgreSQL database is restarted.

Description

Type of change

  • Refactor
  • New feature
  • Bug fix
  • CVE fix
  • Optimization
  • Documentation Update
  • Configuration Update
  • Bump-up dependent library
  • Bump-up library or tool used for development (does not change the final image)
  • CI configuration change
  • Konflux configuration change

Related Tickets & Documents

  • Related Issue #
  • Closes #

Checklist before requesting a review

  • I have performed a self-review of my code.
  • PR has passed all pre-merge test jobs.
  • If it is a core feature, I have added thorough tests.

Testing

  • Please provide detailed steps to perform tests related to this code change.
  • How were the fix/results from this change verified? Please provide relevant screenshots or results.

Summary by CodeRabbit

  • New Features

    • Enhanced the /liveness endpoint to detect repeated PostgreSQL connectivity failures.
    • Returns HTTP 503 with a clear “database unreachable” reason when the failure threshold is reached.
    • Added configurable PostgreSQL timeouts, health-check intervals, and liveness failure thresholds.
    • Liveness responses now optionally include an explanation for unhealthy status.
  • Bug Fixes

    • Improved database connection recovery, transaction safety, and error handling.
    • Added background health monitoring to improve cache readiness reporting.

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Changes

Postgres cache health and liveness

Layer / File(s) Summary
Health configuration and response contracts
ols/constants.py, ols/app/models/config.py, ols/app/models/models.py, docs/openapi.json, tests/unit/app/models/test_config.py
Adds Postgres timeout and health-check settings, validates the liveness failure threshold, and adds the optional liveness response reason and HTTP 503 contract.
Postgres health monitoring and failure handling
ols/src/cache/postgres_cache.py, ols/utils/postgres.py, tests/unit/cache/*, tests/unit/utils/test_postgres.py
Adds background health checks, synchronized readiness, consecutive failure tracking, bounded transaction locks, cleanup, retry handling, and CacheError wrapping.
Liveness failure response
ols/app/endpoints/health.py, tests/unit/app/endpoints/test_health.py
Returns HTTP 503 with alive=False and "database unreachable" when Postgres failures reach the configured threshold.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant LivenessEndpoint
  participant PostgresCache
  participant PostgreSQL
  Client->>LivenessEndpoint: GET /liveness
  LivenessEndpoint->>PostgresCache: read consecutive_failures
  PostgresCache-->>LivenessEndpoint: failure count
  PostgresCache->>PostgreSQL: background health check
  PostgreSQL-->>PostgresCache: connection health
  LivenessEndpoint-->>Client: 200 alive or 503 database unreachable
Loading

Suggested reviewers: bparees, tisnik, blublinsky

🚥 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: automatic PostgreSQL recovery after a database restart.
Docstring Coverage ✅ Passed Docstring coverage is 96.97% which is sufficient. The required threshold is 80.00%.
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.

@openshift-ci
openshift-ci Bot requested review from bparees and tisnik June 24, 2026 17:46
@openshift-ci

openshift-ci Bot commented Jun 24, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign blublinsky for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@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: 12

🧹 Nitpick comments (1)
tests/unit/cache/test_postgres_cache.py (1)

836-865: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Exercise the health-check behavior instead of assigning its result.

These tests directly set _health_status and _consecutive_failures, so they would still pass if _health_check_loop() stopped updating either field. Extract a single-iteration health-check helper or inject a controllable sleep/event so the tests drive the success and failure paths. As per coding guidelines, tests/**/*.py: Assert specific values and behaviors in tests, not just that code runs without error.

🤖 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 `@tests/unit/cache/test_postgres_cache.py` around lines 836 - 865, The
health-check tests are bypassing the real behavior by mutating PostgresCache
internals directly, so they won’t catch regressions in _health_check_loop().
Update the tests to drive the loop logic through a single-iteration helper or a
controllable sleep/event on PostgresCache, then assert the resulting
_health_status and _consecutive_failures values after simulated success/failure.
Use the existing PostgresCache, _health_check_loop, _mark_unhealthy, and ready()
symbols to locate the behavior and keep the assertions focused on observable
state changes rather than direct field assignment.

Source: Coding guidelines

🤖 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 `@ols/app/endpoints/health.py`:
- Around line 126-129: The 503 liveness response in the health endpoint is
returning an HTTPException shape instead of the declared LivenessResponse
contract. Update the liveness failure path in the health endpoint so the 503
body matches LivenessResponse (for example, by returning that model directly),
or change the documented 503 response model to the actual error payload; keep
the response schema and the OpenAPI declaration in sync around the liveness
handler.

In `@ols/app/models/config.py`:
- Around line 801-803: The config model currently accepts negative values for
statement_timeout, lock_timeout, and health_check_interval, so tighten
validation in the Pydantic config class that defines these fields. Add explicit
field constraints or validators on statement_timeout and lock_timeout to reject
negative integers, and make health_check_interval strictly positive so the
health-check loop and PostgresCache lock acquisition keep their timing
guarantees. Use the existing config model/field definitions in Config to enforce
these contracts at load time rather than downstream.
- Around line 1166-1167: `OLSConfig.__init__` is currently taking
`liveness_db_failure_threshold` directly from `data.get(...)`, so it can remain
a string or accept invalid values. Parse this field as an int when loading
config, and validate that the value is at least 1; if it is missing,
non-numeric, zero, or negative, raise `checks.InvalidConfigurationError` before
the value is used by `/liveness`.

In `@ols/src/cache/postgres_cache.py`:
- Around line 159-165: Validate the Postgres cache timing settings before they
reach the daemon: `health_check_interval` must not allow negative values or
zero-hot-loop behavior, and `lock_timeout` should reject negative values unless
indefinite blocking is explicitly intended. Add Pydantic validation in
`PostgresConfig` (using appropriate constrained types such as
non-negative/positive ints) and keep the runtime consumers in `PostgresCache`
safe by relying on those validated values in the initialization and health-check
logic. Update any related uses in `PostgresCache` and its config wiring so the
daemon never calls `time.sleep()` or lock acquisition with invalid values.
- Around line 189-219: The health-check path in _connect_health and
_health_check_loop can block indefinitely because neither the psycopg2.connect
call nor the SELECT 1 probe has a timeout. Update the PostgresCache health
connection setup to pass an explicit connect timeout in connect_kwargs and add a
statement timeout before executing the cursor probe, so ready() does not wait on
a hung network or server and _health_status can recover promptly.
- Around line 194-195: The broad exception handling in PostgresCache needs to be
narrowed to satisfy Ruff BLE001. Update the catch blocks in _connect_health,
_health_check_loop, _safe_rollback, and _safe_set_autocommit to handle only the
specific psycopg2 or attribute-related exceptions they can actually raise, and
keep the existing logger/debug behavior in those paths. If any catch-all is
intentionally required to keep the daemon alive, add an explicit # noqa: BLE001
on that except clause rather than leaving a bare except Exception.

In `@ols/utils/postgres.py`:
- Around line 106-108: The new cursor in the statement-timeout setup can leak if
`cursor.execute` fails during schema initialization. Update the `Postgres`
initialization flow around the `cursor = self.connection.cursor()` and `SET
statement_timeout` call to use a `try/finally` so `cursor.close()` always runs,
even on exceptions; keep the fix localized to this cursor-handling block.
- Around line 29-45: The wrapper in postgres connection handling is calling
connectable.connect() before the try/except, so a failed initial connect skips
the same recovery path as query failures. Move the initial connect() call into
the connection-error handling flow inside wrapper() so both the first connect
and the retry go through the same psycopg2.OperationalError/InterfaceError
handling, including _mark_unhealthy() and CacheError wrapping in the reconnect
path.
- Around line 33-45: The `@connection` retry logic in the postgres decorator is
causing duplicate writes for methods like PostgresCache.insert_or_append because
the operation may have already committed before reconnect and retry. Update the
retry behavior in the connection wrapper to avoid automatically retrying
write/mutating methods after OperationalError/InterfaceError, and keep the retry
path only for safe read operations or explicitly idempotent calls identified by
the wrapped function name.

In `@tests/unit/cache/test_postgres_cache.py`:
- Around line 20-23: The new pytest fixture and several test functions in
Postgres cache tests are missing type annotations. Update _suppress_health_loop
to declare Generator[None, None, None] as its return type, and add -> None to
the affected test functions in test_postgres_cache so the new autouse fixture
and tests are fully typed and consistent with the rest of the module.

In `@tests/unit/utils/test_postgres.py`:
- Around line 108-147: Add explicit -> None return annotations to the new test
methods in test_postgres.py, including the helper failing_connect nested in
test_connection_error_reconnect_failure_raises_cache_error, to keep the test
signatures consistent. Also tighten
test_database_error_wraps_in_cache_error_no_retry by asserting the Connectable
instance does not attempt reconnect/retry when do_work_database_error is called;
use the existing Connectable, connect, and do_work_database_error symbols to
place the assertion near the current CacheError check.
- Around line 124-129: The test
`test_database_error_wraps_in_cache_error_no_retry` only verifies that
`do_work_database_error()` raises `CacheError`, but it does not prove the
reconnect path was skipped. After the initial `c.connect()` call, patch or mock
`Connectable.connect` and assert it is not called when
`do_work_database_error()` raises, so this test checks the “no retry” behavior
as well as the `CacheError` wrapping.

---

Nitpick comments:
In `@tests/unit/cache/test_postgres_cache.py`:
- Around line 836-865: The health-check tests are bypassing the real behavior by
mutating PostgresCache internals directly, so they won’t catch regressions in
_health_check_loop(). Update the tests to drive the loop logic through a
single-iteration helper or a controllable sleep/event on PostgresCache, then
assert the resulting _health_status and _consecutive_failures values after
simulated success/failure. Use the existing PostgresCache, _health_check_loop,
_mark_unhealthy, and ready() symbols to locate the behavior and keep the
assertions focused on observable state changes rather than direct field
assignment.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 3804766b-967a-49bb-b307-520f135d2133

📥 Commits

Reviewing files that changed from the base of the PR and between b51ca02 and 9868f61.

📒 Files selected for processing (10)
  • ols/app/endpoints/health.py
  • ols/app/models/config.py
  • ols/app/models/models.py
  • ols/constants.py
  • ols/src/cache/postgres_cache.py
  • ols/utils/postgres.py
  • tests/unit/app/endpoints/test_health.py
  • tests/unit/app/models/test_config.py
  • tests/unit/cache/test_postgres_cache.py
  • tests/unit/utils/test_postgres.py

Comment thread ols/app/endpoints/health.py
Comment thread ols/app/models/config.py Outdated
Comment thread ols/app/models/config.py
Comment on lines +159 to +165
self._lock_timeout = config.lock_timeout
self.capacity = config.max_entries

self._health_status = True
self._consecutive_failures = 0
self._health_lock = threading.Lock()
self._health_check_interval = config.health_check_interval

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

Validate timeout and interval values before the daemon uses them.

health_check_interval reaches time.sleep() before the try, so a negative value kills the health thread and 0 creates a hot polling loop. lock_timeout should also reject negative values unless indefinite blocking is intentional. The related PostgresConfig contract currently exposes these as plain int; validate them there with PositiveInt/non-negative types. As per coding guidelines, ols/**/*.py: Use Pydantic models for configuration and data validation.

Also applies to: 211-215, 254-257

🤖 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 `@ols/src/cache/postgres_cache.py` around lines 159 - 165, Validate the
Postgres cache timing settings before they reach the daemon:
`health_check_interval` must not allow negative values or zero-hot-loop
behavior, and `lock_timeout` should reject negative values unless indefinite
blocking is explicitly intended. Add Pydantic validation in `PostgresConfig`
(using appropriate constrained types such as non-negative/positive ints) and
keep the runtime consumers in `PostgresCache` safe by relying on those validated
values in the initialization and health-check logic. Update any related uses in
`PostgresCache` and its config wiring so the daemon never calls `time.sleep()`
or lock acquisition with invalid values.

Source: Coding guidelines

Comment thread ols/src/cache/postgres_cache.py Outdated
Comment thread ols/utils/postgres.py Outdated
Comment thread ols/utils/postgres.py Outdated
Comment thread tests/unit/cache/test_postgres_cache.py Outdated
Comment thread tests/unit/utils/test_postgres.py Outdated
Comment thread tests/unit/utils/test_postgres.py Outdated
@sriroopar
sriroopar force-pushed the postgres-auto-recovery branch 3 times, most recently from 15f8455 to 6e37719 Compare June 24, 2026 20:32

@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 `@ols/src/cache/postgres_cache.py`:
- Around line 350-353: The DatabaseError handling in PostgresCache should use
the same rollback protection as the operational-error path, because calling
connection.rollback() directly can hide the original psycopg2.DatabaseError and
prevent the CacheError wrapping from happening. Update the exception blocks in
PostgresCache.insert_or_append and the other matching DatabaseError path to call
_safe_rollback() instead of self.connection.rollback(), while keeping the
existing logger.error and CacheError("PostgresCache.insert_or_append", e)
behavior intact.
- Around line 184-188: The `_mark_unhealthy()` path only updates
`_health_status`, so repeated request-path failures from the `@connection` retry
flow do not advance `_consecutive_failures` toward the liveness threshold.
Update `_mark_unhealthy()` in `PostgresCache` to also increment the failure
counter under `_health_lock`, keeping the unhealthy flag and failure tracking in
sync so `is_healthy`/liveness checks can trip promptly after repeated
cache-operation DB failures.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: c84be3c2-a66c-475a-91d3-5d332a00fb83

📥 Commits

Reviewing files that changed from the base of the PR and between 9868f61 and 6e37719.

📒 Files selected for processing (10)
  • docs/openapi.json
  • ols/app/endpoints/health.py
  • ols/app/models/config.py
  • ols/constants.py
  • ols/src/cache/postgres_cache.py
  • ols/utils/postgres.py
  • tests/unit/app/endpoints/test_health.py
  • tests/unit/app/models/test_config.py
  • tests/unit/cache/test_postgres_cache.py
  • tests/unit/utils/test_postgres.py
🚧 Files skipped from review as they are similar to previous changes (8)
  • ols/app/endpoints/health.py
  • ols/constants.py
  • tests/unit/cache/test_postgres_cache.py
  • tests/unit/app/endpoints/test_health.py
  • ols/app/models/config.py
  • tests/unit/utils/test_postgres.py
  • ols/utils/postgres.py
  • tests/unit/app/models/test_config.py

Comment thread ols/src/cache/postgres_cache.py
Comment thread ols/src/cache/postgres_cache.py

@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.

🧹 Nitpick comments (2)
ols/app/endpoints/health.py (1)

134-144: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Encapsulate health-state access instead of reaching into PostgresCache internals.

The endpoint directly touches cache._health_lock and cache._consecutive_failures, private attributes of PostgresCache. This crosses the app/cache layer boundary and couples the endpoint to the cache's internal locking scheme.

Consider exposing a public method (e.g. PostgresCache.is_healthy(threshold) -> bool) that encapsulates the lock + comparison, so health.py only calls a stable public API.

As per coding guidelines, "Respect architectural boundaries: Do not cross module or layer boundaries, even when it is the shorter path."

♻️ Proposed refactor
-    cache = config._conversation_cache
-    if isinstance(cache, PostgresCache):
-        threshold = config.ols_config.liveness_db_failure_threshold
-        with cache._health_lock:
-            failures = cache._consecutive_failures
-        if failures >= threshold:
-            response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
-            return LivenessResponse(alive=False, reason="database unreachable")
+    cache = config._conversation_cache
+    if isinstance(cache, PostgresCache):
+        threshold = config.ols_config.liveness_db_failure_threshold
+        if not cache.is_healthy(threshold):
+            response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
+            return LivenessResponse(alive=False, reason="database unreachable")
🤖 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 `@ols/app/endpoints/health.py` around lines 134 - 144, The liveness probe in
liveness_probe_get_method is reaching into PostgresCache private state
(_health_lock and _consecutive_failures), so move that lock-and-compare logic
behind a public API on PostgresCache such as is_healthy(threshold) -> bool.
Update health.py to call only the new public method and keep the endpoint from
depending on cache internals.

Source: Coding guidelines

tests/unit/app/endpoints/test_health.py (1)

230-283: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Hoist repeated inline PostgresConfig import to module level.

from ols.app.models.config import PostgresConfig is repeated inline in three test functions (lines 233, 251, 271). Unless this is needed to avoid a circular import, move it to the top of the file.

As per coding guidelines, "module-level imports by default, inline imports only when needed for circular dependencies or deferred optional heavy dependencies."

♻️ Proposed refactor
+from ols.app.models.config import PostgresConfig
 from ols.src.cache.postgres_cache import PostgresCache

And remove the three inline from ols.app.models.config import PostgresConfig occurrences inside the test bodies.

🤖 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 `@tests/unit/app/endpoints/test_health.py` around lines 230 - 283, The three
liveness probe tests repeat an inline PostgresConfig import inside each test
body; hoist that import to the module level in the test file and remove the
repeated local imports from
test_liveness_probe_returns_alive_when_postgres_healthy,
test_liveness_probe_returns_503_when_postgres_unhealthy, and
test_liveness_probe_returns_alive_when_below_threshold. Keep the tests using
PostgresConfig exactly as before, just reference the shared module-level import
to follow the import guideline unless a circular dependency requires otherwise.

Source: Coding guidelines

🤖 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.

Nitpick comments:
In `@ols/app/endpoints/health.py`:
- Around line 134-144: The liveness probe in liveness_probe_get_method is
reaching into PostgresCache private state (_health_lock and
_consecutive_failures), so move that lock-and-compare logic behind a public API
on PostgresCache such as is_healthy(threshold) -> bool. Update health.py to call
only the new public method and keep the endpoint from depending on cache
internals.

In `@tests/unit/app/endpoints/test_health.py`:
- Around line 230-283: The three liveness probe tests repeat an inline
PostgresConfig import inside each test body; hoist that import to the module
level in the test file and remove the repeated local imports from
test_liveness_probe_returns_alive_when_postgres_healthy,
test_liveness_probe_returns_503_when_postgres_unhealthy, and
test_liveness_probe_returns_alive_when_below_threshold. Keep the tests using
PostgresConfig exactly as before, just reference the shared module-level import
to follow the import guideline unless a circular dependency requires otherwise.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: openshift/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 51571fc0-22a7-4977-978e-38af5cbf15fd

📥 Commits

Reviewing files that changed from the base of the PR and between 6e37719 and 7e46a50.

📒 Files selected for processing (8)
  • ols/app/endpoints/health.py
  • ols/app/models/config.py
  • ols/app/models/models.py
  • ols/src/cache/postgres_cache.py
  • ols/utils/postgres.py
  • tests/unit/app/endpoints/test_health.py
  • tests/unit/cache/test_postgres_cache.py
  • tests/unit/utils/test_postgres.py
🚧 Files skipped from review as they are similar to previous changes (5)
  • ols/app/models/config.py
  • ols/src/cache/postgres_cache.py
  • tests/unit/cache/test_postgres_cache.py
  • ols/utils/postgres.py
  • tests/unit/utils/test_postgres.py

@sriroopar
sriroopar force-pushed the postgres-auto-recovery branch from 7e46a50 to c81358c Compare July 2, 2026 13:22
@sriroopar

sriroopar commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Adversarial Code Review - PostgreSQL Auto-Recovery (OLS-3221)

Critical Issues

1. Race Condition: Failure Counter Reset vs Increment (postgres_cache.py:184-188, 226-230)

There's a race condition between _mark_unhealthy() (called from request threads via @connection decorator) and the health check loop resetting _consecutive_failures:

# Thread A (request): _mark_unhealthy()
self._consecutive_failures += 1  # increments to 3

# Thread B (health loop): success path
self._consecutive_failures = 0   # resets to 0

# Thread A continues but the failure was "lost"

Impact: Liveness check could oscillate between healthy/unhealthy unpredictably.

Suggestion: Consider using an atomic counter or ensuring the health loop only resets failures after a configurable number of successful probes.


2. Potential Data Loss: @connection Retries Write Operations (postgres.py:31-45)

The @connection decorator retries after OperationalError/InterfaceError, but insert_or_append could have partially committed before the connection error:

# In insert_or_append: connection commits successfully
self.connection.commit()  # <-- succeeds
# Then network drops here, raising OperationalError
# Decorator catches and retries entire operation
# Result: duplicate entry appended

Impact: Message duplication in conversation history after network glitches.

Suggestion: Add idempotency tracking or disable retry for write operations. The advisory lock helps within a transaction but doesn't prevent retry-induced duplicates.


3. Liveness Endpoint Accesses Private Attributes (health.py:139-140)

with cache._health_lock:
    failures = cache._consecutive_failures

Accessing _health_lock and _consecutive_failures directly violates encapsulation and creates tight coupling.

Suggestion: Add a public method like get_consecutive_failures() or is_healthy_for_liveness(threshold: int) to PostgresCache.


Medium Issues

4. Health Check Thread Has No Graceful Shutdown (postgres_cache.py:217-244)

def _health_check_loop(self) -> None:
    while True:  # No exit condition
        time.sleep(self._health_check_interval)

The daemon thread runs forever with no way to stop it cleanly. While daemon=True means it won't block process exit, this prevents clean shutdown in tests and can cause issues with connection cleanup.

Suggestion: Add a _shutdown_event = threading.Event() and check it in the loop.


5. Health Check Doesn't Run Immediately (postgres_cache.py:219-220)

def _health_check_loop(self) -> None:
    while True:
        time.sleep(self._health_check_interval)  # Sleeps FIRST
        # ... then checks

Initial health status is True but the first actual check happens after health_check_interval seconds (default 30s). If the DB is unreachable at startup, ready() returns True for 30 seconds.

Suggestion: Perform initial health check before sleeping, or set initial _health_status = False until first successful check.


6. Lock Timeout of 0 Causes Immediate Failure (postgres_cache.py:260-264)

def _acquire_lock(self) -> None:
    if not self._tx_lock.acquire(timeout=self._lock_timeout):
        raise CacheError("lock acquisition timeout")

If lock_timeout is configured as 0, every cache operation will fail immediately. The NonNegativeInt validation allows 0.

Suggestion: Either use PositiveInt for lock_timeout in config, or handle 0 specially (e.g., use blocking=True for infinite wait).


7. Health Connection State Inconsistency (postgres_cache.py:190-210)

def _connect_health(self) -> None:
    if self._health_connection is not None:
        try:
            self._health_connection.close()
        except Exception:
            pass  # Old connection closed or failed
    # ... if connect() fails here, _health_connection is still the old (closed) object
    self._health_connection = psycopg2.connect(**connect_kwargs)

If psycopg2.connect() fails, _health_connection still references the old closed connection.

Suggestion: Set self._health_connection = None before attempting new connection.


Minor Issues

8. Hardcoded connect_timeout: 10 (postgres_cache.py:207)

The health check connection timeout is hardcoded to 10 seconds. This should be configurable via PostgresConfig.


9. No Test for Concurrent Access Under Lock Contention

The test suite doesn't verify behavior when multiple threads compete for _tx_lock with timeout enabled. Consider adding a stress test.


Summary

The auto-recovery implementation is a significant improvement over the previous state, but the thread safety and retry semantics need attention before production deployment. The most critical issues are:

  1. Race condition in failure counting
  2. Potential duplicate writes on retry
  3. Breaking encapsulation in liveness endpoint

@sriroopar

Copy link
Copy Markdown
Contributor Author

Adversarial Review - Updated Code (Follow-up)

Issues Addressed ✅

Great progress! The following issues from my previous review have been addressed:

  1. Encapsulation - Now using cache.consecutive_failures property instead of private attributes
  2. Graceful shutdown - Added shutdown() method and _shutdown_event
  3. Configurable connect_timeout - Added health_check_connect_timeout to PostgresConfig
  4. Health check runs immediately - Loop now checks first, then waits at end
  5. Initial connect in try block - Moved inside try in @connection decorator

Remaining Issues

1. Health Connection State Inconsistency (postgres_cache.py:191-211)

Still not addressed:

def _connect_health(self) -> None:
    if self._health_connection is not None:
        try:
            self._health_connection.close()
        except Exception:
            pass
    # If psycopg2.connect() fails here, _health_connection still references
    # the old (closed) connection object
    self._health_connection = psycopg2.connect(**connect_kwargs)

Fix: Set self._health_connection = None after closing and before attempting new connection:

if self._health_connection is not None:
    try:
        self._health_connection.close()
    except Exception as close_err:
        logger.debug("Failed to close old health connection: %s", close_err)
    self._health_connection = None  # <-- Add this

2. shutdown() Doesn't Clean Up Resources (postgres_cache.py:218-220)

def shutdown(self) -> None:
    """Signal the health-check thread to stop."""
    self._shutdown_event.set()

The method only signals shutdown but doesn't:

  • Wait for the thread to finish (join())
  • Close _health_connection
  • Close the main connection

Suggestion:

def shutdown(self, timeout: float = 5.0) -> None:
    """Signal the health-check thread to stop and clean up."""
    self._shutdown_event.set()
    self._health_thread.join(timeout=timeout)
    if self._health_connection:
        try:
            self._health_connection.close()
        except Exception:
            pass

3. Lock Timeout of 0 Still Causes Immediate Failure (postgres_cache.py:265-269)

lock_timeout uses NonNegativeInt which allows 0. If configured as 0, every cache operation fails immediately.

Suggestion: Either:

  • Use PositiveInt for lock_timeout in config, OR
  • Handle 0 specially to mean "infinite wait":
def _acquire_lock(self) -> None:
    timeout = self._lock_timeout if self._lock_timeout > 0 else None
    if not self._tx_lock.acquire(timeout=timeout):
        raise CacheError("lock acquisition timeout")

4. Startup Race: First Health Check May Increment Failures (postgres_cache.py:222-249)

On first loop iteration, _health_connection is None, triggering _connect_health(). If DB is unreachable at startup, this increments _consecutive_failures immediately, potentially triggering liveness failure before any real operations have been attempted.

This may be acceptable behavior, but worth documenting.


Minor Observations

5. Potential Duplicate Writes on Retry (Unchanged)

This is documented as a known trade-off. The advisory lock helps within a transaction, but the @connection retry could still cause duplicates if the commit succeeds but connection drops immediately after. Consider adding a note in the docstring.


Summary

The code is much improved! The main remaining issues are:

  1. Health connection state - should be set to None before reconnect attempt
  2. shutdown() cleanup - should join thread and close connections
  3. Lock timeout edge case - 0 value handling

None of these are blockers, but fixing #1 and #2 would improve robustness.

@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: 1

🤖 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 `@ols/src/cache/postgres_cache.py`:
- Around line 219-230: The Postgres cache shutdown path is implemented in
PostgresCache.shutdown() but is never invoked by the app lifecycle. Update the
app owner in main application startup/shutdown flow to call
config.conversation_cache.shutdown() during process teardown so the health
thread is joined and the connection is closed. Use the existing
CacheFactory-created cache instance and wire the call into the app
shutdown/lifespan handling in ols/app/main.py.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: b907267e-0034-4eef-b88d-2b21f0a353dc

📥 Commits

Reviewing files that changed from the base of the PR and between 7e46a50 and f7d7700.

📒 Files selected for processing (9)
  • ols/app/endpoints/health.py
  • ols/app/models/config.py
  • ols/app/models/models.py
  • ols/constants.py
  • ols/src/cache/postgres_cache.py
  • ols/utils/postgres.py
  • tests/unit/app/endpoints/test_health.py
  • tests/unit/cache/test_postgres_cache.py
  • tests/unit/utils/test_postgres.py
✅ Files skipped from review due to trivial changes (1)
  • ols/constants.py
🚧 Files skipped from review as they are similar to previous changes (7)
  • ols/app/models/models.py
  • tests/unit/app/endpoints/test_health.py
  • ols/app/endpoints/health.py
  • ols/utils/postgres.py
  • tests/unit/cache/test_postgres_cache.py
  • ols/app/models/config.py
  • tests/unit/utils/test_postgres.py

@sriroopar
sriroopar force-pushed the postgres-auto-recovery branch from cdb26ca to c1bdf14 Compare July 9, 2026 17:26
@openshift-ci openshift-ci Bot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Jul 9, 2026
@sriroopar
sriroopar force-pushed the postgres-auto-recovery branch from c1bdf14 to ff59440 Compare July 9, 2026 17:36
@openshift-ci openshift-ci Bot removed the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Jul 9, 2026
@sriroopar

Copy link
Copy Markdown
Contributor Author

/retest

@sriroopar
sriroopar force-pushed the postgres-auto-recovery branch from e909bf9 to 302a4f0 Compare July 13, 2026 15:39
@sriroopar
sriroopar force-pushed the postgres-auto-recovery branch 3 times, most recently from 6d3dcb6 to de68693 Compare August 3, 2026 11:03
@sriroopar
sriroopar force-pushed the postgres-auto-recovery branch from de68693 to 148d383 Compare August 6, 2026 13:16
@sriroopar

Copy link
Copy Markdown
Contributor Author

/retest

@sriroopar
sriroopar force-pushed the postgres-auto-recovery branch from 148d383 to 58fa0a1 Compare August 10, 2026 13:50
@sriroopar

Copy link
Copy Markdown
Contributor Author

/test e2e-ols-cluster

@sriroopar
sriroopar force-pushed the postgres-auto-recovery branch 2 times, most recently from c0dda09 to ae8a382 Compare August 11, 2026 03:44
@xrajesh

xrajesh commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
tests/unit/cache/test_postgres_cache_transaction_fix.py (1)

32-32: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add return annotations to all test functions.

Add -> None to each test function. Strict MyPy requires complete function signatures.

Proposed fix
-def test_insert_or_append_transaction_status_check_on_success():
+def test_insert_or_append_transaction_status_check_on_success() -> None:

As per coding guidelines, “Use type hints for all function signatures.”

Also applies to: 60-60, 93-93, 117-117, 145-145

🤖 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 `@tests/unit/cache/test_postgres_cache_transaction_fix.py` at line 32, Add ->
None return annotations to every test function in this file, including
test_insert_or_append_transaction_status_check_on_success and the other
functions identified in the review, so all test signatures satisfy strict MyPy.

Source: Coding guidelines

ols/app/endpoints/health.py (1)

55-60: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Stop timed-out LLM invocations from accumulating worker threads.

future.result(timeout=...) stops waiting, but executor.shutdown(wait=False, cancel_futures=True) cannot cancel an already-running bare_llm.invoke(). A blocked provider call can keep its worker alive after llm_is_ready() returns False, and repeated probes can accumulate live workers. Enforce a deadline on the underlying provider I/O and use native async cancellation when supported. Add a regression test for repeated timed-out probes.

🤖 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 `@ols/app/endpoints/health.py` around lines 55 - 60, Update llm_is_ready and
the underlying bare_llm provider invocation to enforce a real I/O deadline,
rather than relying only on future.result(timeout=...). Use native async
cancellation when the provider supports it, and ensure timed-out probes do not
leave running worker threads behind. Add a regression test that performs
repeated timed-out readiness probes and verifies workers do not accumulate.

Source: Coding guidelines

🧹 Nitpick comments (1)
tests/unit/cache/test_postgres_cache_transaction_fix.py (1)

38-43: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove comments that restate the test code.

The comments repeat mock setup, assertions, and control flow already expressed by the test names and statements. Keep the test docstrings and remove these redundant comments.

As per coding guidelines, “Avoid comments unless explicitly requested; make code self-documenting.”

Also applies to: 51-56, 63-67, 83-89, 101-103, 121-123, 128-130, 140-141, 153-168

🤖 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 `@tests/unit/cache/test_postgres_cache_transaction_fix.py` around lines 38 -
43, Remove the redundant explanatory comments throughout the affected test
blocks, including the setup and transaction-status comments around
mock_connection and mock_cursor. Keep the test docstrings and all executable
statements unchanged so the tests remain self-documenting.

Source: Coding guidelines

🤖 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 `@ols/src/cache/postgres_cache.py`:
- Around line 227-238: Move health-connection closing into a finally block owned
by _health_check_loop, ensuring every connection created by the thread is closed
when the loop exits. Synchronize all reads, writes, and closes of
_health_connection so shutdown cannot race with probe creation or cleanup;
update shutdown to signal and join the thread without independently closing a
potentially active connection.
- Around line 391-393: Update the mutation method containing the psycopg2
OperationalError/InterfaceError handler so ambiguous commit failures are not
retried by the `@connection` decorator: either add an idempotency key that makes
repeated appends and message_count updates safe, or convert the ambiguous write
outcome into a CacheError. Preserve rollback behavior for clearly failed writes
and ensure a committed-before-disconnect retry cannot duplicate cache_entry or
message_count.
- Around line 161-162: Update the first failed health-probe path in the
surrounding health-check method to immediately set _health_status to False and
increment _consecutive_failures, including the corresponding path around the
second referenced location. Preserve the existing success-state reset behavior
and failure logging.

In `@ols/utils/postgres.py`:
- Around line 33-61: Update the connection wrapper around
connectable.connected() so an initially disconnected client acquires _tx_lock
when available, reconnects only after rechecking connectivity, and calls
_mark_healthy after successful recovery. Also wrap OperationalError and
InterfaceError raised by the retry invocation of f(connectable, *args, **kwargs)
in CacheError, preserving the original exception as the cause.

---

Outside diff comments:
In `@ols/app/endpoints/health.py`:
- Around line 55-60: Update llm_is_ready and the underlying bare_llm provider
invocation to enforce a real I/O deadline, rather than relying only on
future.result(timeout=...). Use native async cancellation when the provider
supports it, and ensure timed-out probes do not leave running worker threads
behind. Add a regression test that performs repeated timed-out readiness probes
and verifies workers do not accumulate.

In `@tests/unit/cache/test_postgres_cache_transaction_fix.py`:
- Line 32: Add -> None return annotations to every test function in this file,
including test_insert_or_append_transaction_status_check_on_success and the
other functions identified in the review, so all test signatures satisfy strict
MyPy.

---

Nitpick comments:
In `@tests/unit/cache/test_postgres_cache_transaction_fix.py`:
- Around line 38-43: Remove the redundant explanatory comments throughout the
affected test blocks, including the setup and transaction-status comments around
mock_connection and mock_cursor. Keep the test docstrings and all executable
statements unchanged so the tests remain self-documenting.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a1fd023-159d-49c9-8797-03cd76d24cfa

📥 Commits

Reviewing files that changed from the base of the PR and between f7d7700 and ae8a382.

📒 Files selected for processing (11)
  • docs/openapi.json
  • ols/app/endpoints/health.py
  • ols/app/models/config.py
  • ols/app/models/models.py
  • ols/constants.py
  • ols/src/cache/postgres_cache.py
  • ols/utils/postgres.py
  • tests/unit/app/endpoints/test_health.py
  • tests/unit/app/models/test_config.py
  • tests/unit/cache/test_postgres_cache.py
  • tests/unit/cache/test_postgres_cache_transaction_fix.py
🚧 Files skipped from review as they are similar to previous changes (7)
  • ols/app/models/models.py
  • docs/openapi.json
  • ols/constants.py
  • tests/unit/app/models/test_config.py
  • tests/unit/app/endpoints/test_health.py
  • tests/unit/cache/test_postgres_cache.py
  • ols/app/models/config.py

Comment thread ols/src/cache/postgres_cache.py
Comment thread ols/src/cache/postgres_cache.py Outdated
Comment thread ols/src/cache/postgres_cache.py
Comment thread ols/utils/postgres.py Outdated
Comment on lines +33 to +61
try:
if not connectable.connected():
connectable.connect()
return f(connectable, *args, **kwargs)
except (psycopg2.OperationalError, psycopg2.InterfaceError) as e:
logger.warning(
"Connection error in %s, attempting reconnect: %s", f.__name__, e
)
if hasattr(connectable, "_mark_unhealthy"):
connectable._mark_unhealthy()
tx_lock = getattr(connectable, "_tx_lock", None)
if tx_lock is not None:
with tx_lock:
try:
connectable.connect()
except Exception as reconnect_err:
raise CacheError(
f"reconnect failed in {f.__name__}", reconnect_err
) from reconnect_err
else:
try:
connectable.connect()
except Exception as reconnect_err:
raise CacheError(
f"reconnect failed in {f.__name__}", reconnect_err
) from reconnect_err
if hasattr(connectable, "_mark_healthy"):
connectable._mark_healthy()
return f(connectable, *args, **kwargs)

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- file map ---'
fd -i 'postgres.py|postgres|cache' . | head -80

printf '%s\n' '--- target file ---'
wc -l ols/utils/postgres.py
cat -n ols/utils/postgres.py

printf '%s\n' '--- related symbols and decorator usages ---'
rg -n --glob '*.py' '_tx_lock|_mark_unhealthy|_mark_healthy|connected\(\)|CacheError|postgres_retry|retry' ols tests 2>/dev/null | head -300

Repository: openshift/lightspeed-service

Length of output: 26131


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- PostgresCache structure and lifecycle ---'
ast-grep outline ols/src/cache/postgres_cache.py --match 'class PostgresCache' --view expanded
sed -n '130,220p' ols/src/cache/postgres_cache.py
sed -n '270,345p' ols/src/cache/postgres_cache.py
sed -n '360,455p' ols/src/cache/postgres_cache.py
sed -n '455,535p' ols/src/cache/postgres_cache.py

printf '%s\n' '--- decorator tests ---'
cat -n tests/unit/utils/test_postgres.py | sed -n '1,180p'

printf '%s\n' '--- transaction-fix tests ---'
cat -n tests/unit/cache/test_postgres_cache_transaction_fix.py | sed -n '1,180p'

printf '%s\n' '--- health endpoint and health-state consumers ---'
rg -n -C 5 --glob '*.py' 'health|_healthy|_mark_unhealthy|_mark_healthy|503' ols/src/cache/postgres_cache.py ols/src/app tests/unit/cache tests/unit/app/endpoints/test_health.py

Repository: openshift/lightspeed-service

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- readiness implementation ---'
fd -i 'health.py' ols
health_file="$(fd -i -t f 'health.py' ols | head -1)"
wc -l "$health_file"
rg -n -C 8 'ready\(|consecutive_failures|503|HTTPException|cache' "$health_file"

printf '%s\n' '--- exact decorated cache method boundaries ---'
sed -n '292,410p' ols/src/cache/postgres_cache.py
sed -n '430,525p' ols/src/cache/postgres_cache.py

printf '%s\n' '--- all decorator definitions and uses in application code ---'
rg -n -C 2 --glob '*.py' '`@connection`|def connection\(' ols tests/unit

printf '%s\n' '--- focused health tests involving cache readiness ---'
rg -n -C 10 'ready\(|_health_status|503|consecutive_failures|_mark_unhealthy|_mark_healthy' tests/unit/app/endpoints/test_health.py tests/unit/cache/test_postgres_cache.py

Repository: openshift/lightspeed-service

Length of output: 45421


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import ast
import threading
from dataclasses import dataclass

source = open("ols/utils/postgres.py", encoding="utf-8").read()
tree = ast.parse(source)
connection = next(
    node for node in tree.body
    if isinstance(node, ast.FunctionDef) and node.name == "connection"
)
wrapper = next(
    node for node in ast.walk(connection)
    if isinstance(node, ast.FunctionDef) and node.name == "wrapper"
)
outer_try = next(node for node in wrapper.body if isinstance(node, ast.Try))
print("outer_try_handlers:", [
    ast.unparse(handler.type) for handler in outer_try.handlers
])
print("outer_try_has_retry_return:", any(
    isinstance(node, ast.Return)
    and isinstance(node.value, ast.Call)
    and isinstance(node.value.func, ast.Name)
    and node.value.func.id == "f"
    for node in ast.walk(outer_try)
))
print("retry_return_is_inside_operational_handler:", any(
    isinstance(node, ast.Return)
    and isinstance(node.value, ast.Call)
    and isinstance(node.value.func, ast.Name)
    and node.value.func.id == "f"
    for node in ast.walk(outer_try.handlers[0])
))

class OperationalError(Exception):
    pass

class InterfaceError(Exception):
    pass

class DatabaseError(Exception):
    pass

class CacheError(Exception):
    pass

def exact_wrapper(f):
    def wrapper(connectable, *args, **kwargs):
        try:
            if not connectable.connected():
                connectable.connect()
            return f(connectable, *args, **kwargs)
        except (OperationalError, InterfaceError) as e:
            connectable.mark_unhealthy()
            connectable.connect()
            connectable.mark_healthy()
            return f(connectable, *args, **kwargs)
        except DatabaseError as e:
            raise CacheError(str(e)) from e
    return wrapper

`@dataclass`
class RetryProbe:
    calls: int = 0
    healthy: bool = True
    reconnects: int = 0

    def connected(self):
        return True

    def connect(self):
        self.reconnects += 1

    def mark_unhealthy(self):
        self.healthy = False

    def mark_healthy(self):
        self.healthy = True

probe = RetryProbe()

`@exact_wrapper`
def fails_twice(connectable):
    connectable.calls += 1
    raise OperationalError("retry failure")

try:
    fails_twice(probe)
except Exception as exc:
    print("second_retry_exception_type:", type(exc).__name__)
print("second_retry_calls:", probe.calls)
print("second_retry_is_cache_error:", isinstance(exc, CacheError))

class RaceProbe:
    def __init__(self):
        self.connection = None
        self.connect_calls = 0
        self.started = threading.Barrier(2)
        self.release = threading.Barrier(2)
        self.lock = threading.Lock()

    def connected(self):
        return self.connection is not None

    def connect(self):
        with self.lock:
            self.connect_calls += 1
            connection_id = self.connect_calls
        self.started.wait()
        self.release.wait()
        self.connection = connection_id

    def work(self):
        return self.connection

race = RaceProbe()

def preflight_only():
    if not race.connected():
        race.connect()
    race.work()

threads = [threading.Thread(target=preflight_only) for _ in range(2)]
for thread in threads:
    thread.start()
for thread in threads:
    thread.join()
print("concurrent_preflight_connect_calls:", race.connect_calls)
print("concurrent_preflight_final_connection:", race.connection)
PY

Repository: openshift/lightspeed-service

Length of output: 530


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import ast
import threading

source = open("ols/utils/postgres.py", encoding="utf-8").read()
tree = ast.parse(source)
connection = next(
    node for node in tree.body
    if isinstance(node, ast.FunctionDef) and node.name == "connection"
)
wrapper = next(
    node for node in ast.walk(connection)
    if isinstance(node, ast.FunctionDef) and node.name == "wrapper"
)
outer_try = next(node for node in wrapper.body if isinstance(node, ast.Try))
operational_handler = outer_try.handlers[0]
retry_return = next(
    node for node in ast.walk(operational_handler)
    if isinstance(node, ast.Return)
    and isinstance(node.value, ast.Call)
    and isinstance(node.value.func, ast.Name)
    and node.value.func.id == "f"
)
print("retry_return_line:", retry_return.lineno)
print("retry_handler_catches_retry_exception:", any(
    isinstance(node, ast.ExceptHandler)
    for node in ast.walk(operational_handler)
))

class OperationalError(Exception):
    pass

class InterfaceError(Exception):
    pass

class DatabaseError(Exception):
    pass

class CacheError(Exception):
    pass

def exact_wrapper(f):
    def wrapper(connectable, *args, **kwargs):
        try:
            if not connectable.connected():
                connectable.connect()
            return f(connectable, *args, **kwargs)
        except (OperationalError, InterfaceError):
            connectable.mark_unhealthy()
            connectable.connect()
            connectable.mark_healthy()
            return f(connectable, *args, **kwargs)
        except DatabaseError as error:
            raise CacheError(str(error)) from error
    return wrapper

class RetryProbe:
    def __init__(self):
        self.calls = 0
        self.reconnects = 0

    def connected(self):
        return True

    def connect(self):
        self.reconnects += 1

    def mark_unhealthy(self):
        pass

    def mark_healthy(self):
        pass

probe = RetryProbe()

`@exact_wrapper`
def fails_twice(connectable):
    connectable.calls += 1
    raise OperationalError("retry failure")

retry_exception = None
try:
    fails_twice(probe)
except Exception as error:
    retry_exception = error
print("second_retry_exception_type:", type(retry_exception).__name__)
print("second_retry_calls:", probe.calls)
print("second_retry_is_cache_error:", isinstance(retry_exception, CacheError))

class RaceProbe:
    def __init__(self):
        self.connection = None
        self.connect_calls = 0
        self.started = threading.Barrier(2)
        self.release = threading.Barrier(2)
        self.lock = threading.Lock()

    def connected(self):
        return self.connection is not None

    def connect(self):
        with self.lock:
            self.connect_calls += 1
            connection_id = self.connect_calls
        self.started.wait()
        self.release.wait()
        self.connection = connection_id

race = RaceProbe()

def preflight_only():
    if not race.connected():
        race.connect()

threads = [threading.Thread(target=preflight_only) for _ in range(2)]
for thread in threads:
    thread.start()
for thread in threads:
    thread.join()
print("concurrent_preflight_connect_calls:", race.connect_calls)
print("concurrent_preflight_final_connection:", race.connection)
PY

Repository: openshift/lightspeed-service

Length of output: 414


Synchronize reconnects and wrap retry failures.

If connected() returns False, acquire _tx_lock before calling connect(), then recheck connectivity. Mark the cache healthy after recovery so failure counters reset.

The retry call can raise another OperationalError or InterfaceError, which escapes the sibling handlers as a raw exception. Catch and wrap retry failures in CacheError.

🤖 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 `@ols/utils/postgres.py` around lines 33 - 61, Update the connection wrapper
around connectable.connected() so an initially disconnected client acquires
_tx_lock when available, reconnects only after rechecking connectivity, and
calls _mark_healthy after successful recovery. Also wrap OperationalError and
InterfaceError raised by the retry invocation of f(connectable, *args, **kwargs)
in CacheError, preserving the original exception as the cause.

@sriroopar
sriroopar force-pushed the postgres-auto-recovery branch 4 times, most recently from 3e2971d to c554b58 Compare August 12, 2026 03:14
@sriroopar

Copy link
Copy Markdown
Contributor Author

/retest

@sriroopar
sriroopar force-pushed the postgres-auto-recovery branch 6 times, most recently from 55faccd to 0580826 Compare August 18, 2026 18:58
@sriroopar

sriroopar commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Adversarial Review — OLS-3221 PostgreSQL Auto-Recovery

🟡 Thread-Safety: Double-Counting Inflates _consecutive_failures

Both the @connection decorator (via _mark_unhealthy()) and the health-check loop independently increment _consecutive_failures. During a DB outage:

Thread A (request):  _mark_unhealthy() → failures = 1
Thread B (health):   health check fails → failures = 2
Thread A (request):  retry fails, _mark_unhealthy() → failures = 3  (premature 503!)

With liveness_db_failure_threshold=3 (default), two real failures + one co-fire = premature 503 Service Unavailable.

Suggestion: Either dedupe by tracking last-failure timestamp, or have request-path failures delegate to the health loop rather than directly incrementing.


🟡 _mark_healthy() Called Before Retry Succeeds

In _handle_connection_error() (postgres.py:33-45):

_do_reconnect(connectable, func_name)  # reconnect succeeds
if hasattr(connectable, "_mark_healthy"):
    connectable._mark_healthy()  # ← marked healthy NOW
try:
    return f(connectable, *args, **kwargs)  # ← but retry could still fail

If reconnect succeeds but the retry fails with another OperationalError, liveness incorrectly reports healthy until the next health tick (up to 30s).

Suggestion: Call _mark_healthy() only after the retry invocation succeeds.


🟡 ready() Lags Up to 30s on Recovery

def ready(self) -> bool:
    with self._health_lock:
        return self._health_status

This returns the background loop's last-known status. With health_check_interval=30 (default), ready() can return False for up to 30 seconds after the DB recovers. During this window, Kubernetes readiness probes may restart the pod unnecessarily.

Mitigation options:

  • Lower default health_check_interval (e.g. 10s)
  • Perform an on-demand probe inside ready() if status is unhealthy (with rate-limiting)
  • Document that recovery lag is expected and safe

🔵 Nit — _suppress_health_loop Fixture Applied Twice

In tests/unit/cache/test_postgres_cache.py:

pytestmark = pytest.mark.usefixtures("_suppress_health_loop")  # line 14

@pytest.fixture(autouse=True)  # lines 17-20
def _suppress_health_loop() -> Generator[None, None, None]:
    ...

Both autouse=True and pytestmark apply the fixture to all tests — one is redundant. Remove the pytestmark line since autouse=True already covers it.


🔵 Nit — E2E Test Changes Appear Out of Scope

tests/e2e/test_query_endpoint.py and test_streaming_query_endpoint.py add assertions checking for refusal-language patterns. These don't appear related to Postgres auto-recovery. If they fix a regression, that should be a separate PR/commit for traceability.


✅ Core Design Confirmed Sound

  • No deadlock in _handle_connection_error: The lock acquisition path is correct — reconnect holds _tx_lock only if present.
  • Health thread shutdown is correct: Uses _shutdown_event.set() + join(timeout) with connection cleanup in finally.
  • _acquire_lock with bounded timeout: Prevents indefinite hangs — raises CacheError on timeout.
  • Ambiguous commit handling: Correctly wraps commit failures in CacheError rather than silently retrying writes.

Verdict

Needs changes — The thread-safety and recovery-lag issues are medium-severity and should be addressed or documented as known limitations.

@sriroopar

Copy link
Copy Markdown
Contributor Author

Follow-up: Fixes for Medium Issues

I've addressed the three medium issues from the adversarial review:

1. ✅ Fixed: Double-counting of _consecutive_failures

Problem: Both the @connection decorator (via _mark_unhealthy()) and the health-check loop independently incremented _consecutive_failures, causing premature 503s.

Fix: _mark_unhealthy() now only sets _health_status = False without incrementing the counter. Only the health-check loop is the sole authority for _consecutive_failures:

def _mark_unhealthy(self) -> None:
    """Mark health status as unhealthy (from cache operations).

    Only sets status to False without incrementing the failure counter.
    The health-check loop is the sole authority for consecutive_failures
    to avoid double-counting between request threads and the health loop.
    """
    with self._health_lock:
        self._health_status = False

2. ✅ Fixed: _mark_healthy() called before retry succeeds

Problem: In _handle_connection_error(), _mark_healthy() was called right after reconnect, before the retry operation. If the retry failed, liveness incorrectly reported healthy.

Fix: Moved _mark_healthy() call to after the retry succeeds in the @connection decorator:

try:
    result = f(connectable, *args, **kwargs)
    # Only mark healthy AFTER the retry succeeds
    if hasattr(connectable, "_mark_healthy"):
        connectable._mark_healthy()
    return result
except (psycopg2.OperationalError, psycopg2.InterfaceError) as retry_err:
    raise CacheError(...)

3. ✅ Fixed: ready() lags up to 30s on recovery

Problem: ready() only returned the health loop's cached status, which could be stale for up to 30 seconds after DB recovery.

Fix: Added on-demand probe in ready() when status is unhealthy, with rate-limiting (5 seconds) to avoid hammering the DB:

def ready(self) -> bool:
    with self._health_lock:
        if self._health_status:
            return True
        # Rate-limited on-demand probe when unhealthy
        now = time.monotonic()
        if now - self._last_ready_probe < 5.0:
            return False
        self._last_ready_probe = now

    # Do on-demand probe outside the lock
    try:
        if self._health_connection is None or self._health_connection.closed:
            self._connect_health()
        with self._health_connection.cursor() as cursor:
            cursor.execute("SELECT 1")
        # Probe succeeded — mark healthy
        with self._health_lock:
            self._health_status = True
            self._consecutive_failures = 0
        return True
    except Exception:
        return False

All unit tests pass (49 postgres tests + 15 health endpoint tests).

@sriroopar
sriroopar force-pushed the postgres-auto-recovery branch 2 times, most recently from 48ca650 to 114d40f Compare August 18, 2026 20:47
@sriroopar

Copy link
Copy Markdown
Contributor Author

/retest

@sriroopar

Copy link
Copy Markdown
Contributor Author

/test ols-evaluation

@sriroopar
sriroopar force-pushed the postgres-auto-recovery branch 2 times, most recently from db94976 to 9509747 Compare August 20, 2026 20:36
Implement background health-check loop, smarter error classification
in the @connection decorator, operation timeouts, and enhanced
liveness/readiness probes so OLS can recover automatically when the
backing PostgreSQL database is restarted.

Co-authored-by: Cursor <cursoragent@cursor.com>
@sriroopar
sriroopar force-pushed the postgres-auto-recovery branch from 9509747 to e748e2c Compare August 20, 2026 20:56
@sriroopar

Copy link
Copy Markdown
Contributor Author

/retest

2 similar comments
@sriroopar

Copy link
Copy Markdown
Contributor Author

/retest

@sriroopar

Copy link
Copy Markdown
Contributor Author

/retest

@openshift-ci

openshift-ci Bot commented Aug 24, 2026

Copy link
Copy Markdown

@sriroopar: The following tests failed, say /retest to rerun all failed tests or /retest-required to rerun all mandatory failed tests:

Test name Commit Details Required Rerun command
ci/prow/ols-evaluation e748e2c link true /test ols-evaluation
ci/prow/e2e-ols-cluster e748e2c link true /test e2e-ols-cluster

Full PR test history. Your PR dashboard.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. I understand the commands that are listed here.

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.

2 participants