Skip to content

Add database pool max-lifetime recycling#400

Merged
binaryfire merged 6 commits into
0.4from
feat/database-pool-max-lifetime
Jun 24, 2026
Merged

Add database pool max-lifetime recycling#400
binaryfire merged 6 commits into
0.4from
feat/database-pool-max-lifetime

Conversation

@binaryfire

@binaryfire binaryfire commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add a disabled-by-default pool max_lifetime option
  • recycle expired database connection generations before reuse and before heartbeat pinging
  • expose database pool heartbeat timeout and max-lifetime env config, including pgsql-pooled settings
  • document the behavior and add regression coverage for reuse, heartbeat ordering, disabled defaults, and borrowed connection safety

Validation

  • ./vendor/bin/phpunit tests/Integration/Database/PooledConnectionTest.php
  • ./vendor/bin/phpunit tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php
  • ./vendor/bin/phpunit tests/Pool/PoolOptionTest.php
  • composer fix

Summary by CodeRabbit

  • New Features

    • Added configurable maximum connection lifetime option for database connection pools to enable automatic recycling of aged connections
    • Enhanced connection pool heartbeat with support for max lifetime recycling, even through proxies and connection poolers
  • Configuration

    • Updated default database configuration to support environment variable-driven pool settings (DB_HEARTBEAT, DB_HEARTBEAT_TIMEOUT, DB_MAX_LIFETIME)
  • Documentation

    • Expanded documentation to describe connection recycling behavior and max lifetime configuration options

Add max_lifetime support to the shared pool option contract and implementation.

Parse the new option from pool configuration with a disabled-by-default value of -1.0, matching the existing heartbeat disabled convention.

Expose a boot-time setter for tests and startup configuration while preserving worker-lifetime safety guidance.
Track the creation time for each pooled database connection generation.

Reject expired generations during the normal reuse check so a returned connection is reconnected before user code receives it, even when heartbeat is disabled.

Have the database heartbeat discard lifetime-expired idle connections before idle trimming or ping validation, while borrowed connections remain untouched.
Add max_lifetime and env-driven heartbeat timeout examples to the database pool configuration.

Document how heartbeat and max lifetime work together for long-running workers, direct database connections, and proxy or pooler setups.

Keep DB_POOLED_* settings separate for the pgsql-pooled runtime connection.
Cover enabled and disabled database pool max lifetime behavior.

Assert expired returned connections reconnect before reuse, heartbeat discards lifetime-expired idle connections before pinging, and borrowed connections are not recycled by heartbeat.

Add focused PoolOption coverage for the new max lifetime option API.
Remove the completed database max-lifetime recycling todo.

Add follow-up notes for DB lifetime jitter and Redis pool heartbeat/max-lifetime support so those decisions are tracked separately from this implementation.
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@binaryfire, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 16 minutes and 52 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e321f33c-0e6d-4d3f-be0f-1028e8ccbb33

📥 Commits

Reviewing files that changed from the base of the PR and between fc0da0a and 0890415.

📒 Files selected for processing (2)
  • src/database/src/Pool/PooledConnection.php
  • tests/Integration/Database/PooledConnectionTest.php
📝 Walkthrough

Walkthrough

Adds a max_lifetime option to the connection pool that tracks a per-connection generation start time ($createdAt) and discards aged idle connections. PooledConnection gains isLifetimeExpired() and getCreatedAt(), DbPool::heartbeatConnection early-discards lifetime-expired connections, PoolOption/PoolOptionInterface expose the new option, and the foundation database config switches to env-driven float values for heartbeat and max-lifetime settings.

Changes

Max-lifetime connection generation recycling

Layer / File(s) Summary
PoolOption contract and wiring
src/contracts/src/Pool/PoolOptionInterface.php, src/pool/src/PoolOption.php, src/pool/src/Pool.php
PoolOptionInterface declares getMaxLifetime(): float. PoolOption adds a promoted $maxLifetime property (default -1.0) with getter and fluent setter. Pool::initOption() reads max_lifetime from the options array and passes it to the constructor.
PooledConnection lifetime tracking
src/database/src/Pool/PooledConnection.php
Adds protected float $createdAt = 0.0. reconnect() and refresh() reset $createdAt to microtime(true). check() calls isLifetimeExpired($now) early and returns false when the generation is expired. New public getCreatedAt() and isLifetimeExpired(?float $now) methods implement the age check, disabled when maxLifetime <= 0.
DbPool heartbeat integration
src/database/src/Pool/DbPool.php
heartbeatConnection computes $now once, adds an early-discard branch for isLifetimeExpired($now) before any ping attempt, and passes $now to isIdleExpired().
Foundation database config
src/foundation/config/database.php
Replaces hardcoded heartbeat/heartbeat_timeout defaults with env()-driven float values and adds max_lifetime driven by DB_MAX_LIFETIME (or DB_POOLED_MAX_LIFETIME for pgsql-pooled) across mysql, mariadb, pgsql, and pgsql-pooled pool configs.
Tests
tests/Pool/PoolOptionTest.php, tests/Integration/Database/PooledConnectionTest.php, tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php
PoolOptionTest verifies default, constructor, and setter behavior for maxLifetime. PooledConnectionTest adds five cases covering expired-lifetime reconnect, cross-release behavior, disabled lifetime, ping not extending lifetime, and reconnect resetting the timestamp. DbPoolHeartbeatTest adds two heartbeat cases using LifetimeExpiredPingTrackingDbPool/PooledConnection doubles that flag whether ping() was called.
Docs and todo
src/boost/docs/database.md, src/boost/todo.md
database.md updates pool config examples with env-based max_lifetime and extends prose to describe heartbeat plus max-lifetime recycling through proxies. todo.md refines the jitter approach for max_lifetime and adds a Redis pool heartbeat/lifetime todo.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • hypervel/components#399: Introduced the database pool heartbeat sweep mechanism in DbPool::heartbeatConnection and PooledConnection idle-expiry checks that this PR directly extends with lifetime-expiry logic.

Poem

🐇 A connection once aging, ignored by the clock,
Now tracks its own birthday with tick-tock-tick-tock.
When max_lifetime calls, it refreshes with glee,
No stale generation shall linger, you'll see!
The pool hops along, recycling with care — 🌱

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.03% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main feature being introduced: adding database pool max-lifetime recycling functionality.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/database-pool-max-lifetime

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.

@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

🧹 Nitpick comments (1)
src/pool/src/PoolOption.php (1)

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

Extract a shared DEFAULT_MAX_LIFETIME constant to avoid drift.

-1.0 is now a hardcoded default literal and can diverge from other fallbacks over time.

♻️ Suggested refactor
 class PoolOption implements PoolOptionInterface
 {
+    public const DEFAULT_MAX_LIFETIME = -1.0;
+
     public function __construct(
@@
-        private float $maxLifetime = -1.0,
+        private float $maxLifetime = self::DEFAULT_MAX_LIFETIME,
         private array $events = [],
     ) {
     }
 // src/pool/src/Pool.php
-            maxLifetime: $options['max_lifetime'] ?? -1.0,
+            maxLifetime: $options['max_lifetime'] ?? PoolOption::DEFAULT_MAX_LIFETIME,

As per coding guidelines, "Extract DEFAULT_* class constants for property initial values to prevent drift if defaults change."

🤖 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 `@src/pool/src/PoolOption.php` at line 33, In the PoolOption class, extract the
hardcoded default value `-1.0` from the `$maxLifetime` property initialization
as a class constant named `DEFAULT_MAX_LIFETIME`. Create this constant at the
class level and then replace the hardcoded `-1.0` literal in the property
declaration with a reference to this constant to ensure consistency across the
codebase and prevent future drift if the default value needs to be updated.

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 `@tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php`:
- Around line 469-488: The helper classes LifetimeExpiredPingTrackingDbPool and
LifetimeExpiredPingTrackingPooledConnection need to be scoped to this specific
test to avoid class redeclaration collisions. Either move these classes into a
test-specific namespace that includes the test class name (such as
DbPoolHeartbeatTest), or rename them with the DbPoolHeartbeatTest prefix (for
example, DbPoolHeartbeatTestLifetimeExpiredPingTrackingDbPool and
DbPoolHeartbeatTestLifetimeExpiredPingTrackingPooledConnection) to clearly
indicate they are test-specific helper classes.

---

Nitpick comments:
In `@src/pool/src/PoolOption.php`:
- Line 33: In the PoolOption class, extract the hardcoded default value `-1.0`
from the `$maxLifetime` property initialization as a class constant named
`DEFAULT_MAX_LIFETIME`. Create this constant at the class level and then replace
the hardcoded `-1.0` literal in the property declaration with a reference to
this constant to ensure consistency across the codebase and prevent future drift
if the default value needs to be updated.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c3d2b9bc-050e-4a1a-b885-58d88591af2b

📥 Commits

Reviewing files that changed from the base of the PR and between 6b02d31 and fc0da0a.

📒 Files selected for processing (11)
  • src/boost/docs/database.md
  • src/boost/todo.md
  • src/contracts/src/Pool/PoolOptionInterface.php
  • src/database/src/Pool/DbPool.php
  • src/database/src/Pool/PooledConnection.php
  • src/foundation/config/database.php
  • src/pool/src/Pool.php
  • src/pool/src/PoolOption.php
  • tests/Integration/Database/PooledConnectionTest.php
  • tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php
  • tests/Pool/PoolOptionTest.php

Comment thread tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php
@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a disabled-by-default max_lifetime option to the database connection pool, recycling aged connection generations at heartbeat time and at reuse time. The key correctness invariant — never recycling a borrowed connection mid-request — is enforced through the new availableForReuse flag that gates all time-based checks in check().

  • The availableForReuse flag is set on release(), cleared on getActiveConnection(), and reset by reconnect(), correctly protecting open transactions and active borrows from lifetime recycling.
  • Heartbeat ordering is correct: lifetime expiry is checked and discarded before idle expiry, and both happen before the ping, so expired connections are never needlessly pinged.
  • createdAt is reset in both reconnect() (full wrapper reconnect) and refresh() (PDO-level reconnect via the reconnector), keeping the generation clock accurate across both paths.

Confidence Score: 5/5

Safe to merge; the core recycling logic is correct and well-tested across borrowed, idle, and heartbeat paths.

The availableForReuse flag cleanly separates borrowed-connection safety from idle/lifetime checks. All three recycling surfaces (reuse via check(), heartbeat via heartbeatConnection(), and full reconnect via getActiveConnection()) reset or respect the flag correctly. Test coverage spans disabled default, borrowed-connection safety, reuse recycling, heartbeat ordering, ping-doesn't-extend-lifetime, and refresh-resets-lifetime. The one known asymmetry — no min-connections guard on the heartbeat lifetime branch — is acknowledged in todo.md.

src/pool/src/PoolOption.phpmaxLifetime is inserted before events in the constructor, shifting the positional index of events from 8 to 9, which is a minor API break for any callers using positional arguments.

Important Files Changed

Filename Overview
src/database/src/Pool/PooledConnection.php Core implementation: adds createdAt, availableForReuse, isLifetimeExpired(), and refactors check() to gate time-based recycling behind the availableForReuse flag so borrowed connections are never recycled mid-request.
src/database/src/Pool/DbPool.php Heartbeat ordering fix: lifetime expiry is checked and discarded before idle expiry and before ping; no min-connections guard on the lifetime branch (acknowledged in todo.md).
src/pool/src/PoolOption.php Adds maxLifetime constructor parameter and getter/setter; parameter is inserted at position 8, pushing events to position 9, which breaks positional callers that pass events as the 8th argument.
src/pool/src/Pool.php Single-line addition passes max_lifetime option to PoolOption using a named argument; no ordering or logic issues.
src/contracts/src/Pool/PoolOptionInterface.php Adds getMaxLifetime(): float to the interface; consistent with existing getter pattern.
src/foundation/config/database.php Adds max_lifetime and exposes heartbeat/heartbeat_timeout as env-var-driven floats across all four pooled connection blocks.
tests/Integration/Database/PooledConnectionTest.php Good regression coverage: borrowed-connection safety, disabled-by-default, reuse-time recycling, ping-doesn't-extend-lifetime, and refresh-resets-lifetime are all tested.
tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php Adds two heartbeat tests: lifetime-expired idle connections are discarded without pinging (using a custom pool subclass), and borrowed lifetime-expired connections survive heartbeat ticks.
tests/Pool/PoolOptionTest.php New unit tests for PoolOption::getMaxLifetime() — default (-1), configured, and set-and-get paths are covered.
src/boost/docs/database.md Documentation updated to describe max_lifetime behavior and the env vars added for heartbeat and max-lifetime configuration.
src/boost/todo.md Converts the completed max-lifetime task to a follow-up jitter todo; adds a new Redis heartbeat/max-lifetime tracking item.

Reviews (2): Last reviewed commit: "fix(database): avoid recycling active po..." | Re-trigger Greptile

Comment thread src/database/src/Pool/PooledConnection.php Outdated
Comment on lines +206 to +210
if ($connection->isLifetimeExpired($now)) {
$this->discardHeartbeatConnection($connection);

return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Lifetime discard has no min-connections guard

The idle-expiry branch two lines below requires $this->currentConnections > $this->option->getMinConnections() before discarding, preserving at least the minimum pool size. The lifetime-expiry branch added here has no such guard. When a cohort of connections all share the same birth time (typical at worker startup), every heartbeat tick can discard all idle connections at once, momentarily dropping the pool to zero even if min_connections > 1. The next in-bound request then bears the reconnection cost. The todo.md entry on jitter acknowledges this, but noting the asymmetry with the idle guard may help reviewers understand why the behaviour can be surprising without changing configuration.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks. I am intentionally not adding a min_connections guard to the lifetime branch. max_lifetime is an absolute generation cap, so keeping expired connections only to preserve the idle minimum would defeat the setting. The reconnect-wave concern is valid, and src/boost/todo.md tracks jitter as the clean follow-up because that smooths expiry timing without weakening the lifetime guarantee.

Track whether a pooled database wrapper is available for reuse so time-based recycling only runs while the wrapper is idle or being borrowed from the pool again. This prevents both max_lifetime and max_idle_time checks from replacing the underlying connection while a caller still holds the wrapper, preserving active transaction state for repeated getConnection() calls within one borrow window.

Keep invalid and closed connection checks outside the reuse gate so genuinely stale wrappers still reconnect immediately. Leave heartbeat predicates unchanged so idle sweeps can continue discarding expired connections before pinging.

Update pooled connection tests to model last-use updates on reuse, prove expired lifetime and idle windows do not reconnect active borrows, and keep existing released-wrapper reuse coverage intact.
@binaryfire

Copy link
Copy Markdown
Collaborator Author

Follow-up on Greptile’s PoolOption constructor-order note: I’m leaving the current semantic order as-is. There are no in-repo positional new PoolOption(...) call sites, Hypervel 0.4 has no backwards-compatibility burden, and maxLifetime belongs next to maxIdleTime with the lifecycle events array last. Appending it after events would make the constructor shape less clear just to protect hypothetical positional callers.

@binaryfire binaryfire merged commit 8c4de4f into 0.4 Jun 24, 2026
35 checks passed
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.

1 participant